今天我们聊聊IO。
我们选择更好的算法,优化数据结构,可以提升程序的性能,降低时间复杂度,这是我们在写算法题目时的思路。对于任何真正投入使用,复杂的软件项目,只要涉及到优化性能,IO的优化总是最重要的一环。或许是要优化掉重复的REST接口调用,或许是SQL增加索引减少数据读取,或许是使用顺序IO替换随机IO。
当然,这并不代表算法与数据结构没有意义,只是IO相对于CPU与内存的速度,实在是太慢了(数据来自 Latency every programmer should know: 2020):
| 操作 | ||
|---|---|---|
| L1 cache 读写 | 1 ns | |
| CPU指令分支预测错误 | 3 ns | |
| L2 cache 读写 | 4 ns | 4x L1 cache |
| Mutex 加锁/解锁 | 17 ns | |
| 内存读写 | 100 ns | 25x L2 cache, 100x L1 cache |
| 从内存顺序读取1MB数据 | 3,000 ns | |
| 通过1Gb/s的网络发送1M数据 | 100,000 ns | |
| 从SSD随机读 | 16,000 ns | |
| 同一个数据中心内RTT | 500,000 ns | |
| 从SSD顺序读1MB数据 | 49,000 ns | 10x memory |
| 机械硬盘寻址 | 2,000,000 ns | 4x datacenter roundtrip |
| 机械硬盘顺序读1MB数据 | 825,000 ns | 200x memory, 20X SSD |
| 数据包从加州到荷兰再回到加州 | 150,000,000 ns |
所以,为了优化IO大家做了哪些努力呢?我们从最底层的硬件开始,到操作系统,最后再看Java的优化。
硬件视角的IO
最初,发起IO请求与最后的数据拷贝都是CPU完成:
sequenceDiagram
软件(操作系统)->>CPU: IO请求
CPU->>硬盘: IO请求
硬盘->>CPU: IO完成中断
CPU->>内存: 从硬盘缓存拷贝数据到内存
CPU->>软件(操作系统): IO请求完成中断
但是如果数据量比较大时,使用CPU完成拷贝比较浪费。于是人们决定使用专用硬件来处理这个简单的任务,释放CPU去做更复杂的任务:
sequenceDiagram
软件(操作系统)->>CPU: IO请求
CPU->>DMA: IO请求
DMA->>硬盘: IO请求
硬盘->>DMA: IO完成中断
DMA->>内存: 从硬盘缓存拷贝数据到内存
DMA->>CPU: 完成通知
CPU->>软件(操作系统): IO请求完成中断
注:其实我们的流程图与真实的系统还是有许多差异,许多内容出于理解上便利的考虑,都被我们省略了(例如硬盘驱动,硬盘控制器等),如果想要深入,可以参考这篇博客。
操作系统(Linux)视角的IO
在硬件视角IO的流程图中,我们都是使用软件(操作系统)作为IO的直接发起者,这是因为所有的IO都不是应用程序直接发起的。普通用户触发IO(用户态),都是由操作系统操作的(内核态),用户态无法操作内核态的数据,反之可以。这种用户态、内核态的设计是为了保证操作系统的安全性,当然也会带来额外的负担。
下图展现了一次用户发起的IO的调用链:
sequenceDiagram
应用程序->>操作系统: read()系统调用
操作系统->>CPU: IO请求
CPU->>DMA: IO请求
DMA->>硬盘: IO请求
硬盘->>DMA: IO完成中断
DMA->>内存: 从硬盘缓存拷贝数据到内核缓冲区
DMA->>CPU: 完成通知
DMA->>内存: 从内核缓冲区拷贝数据到用户缓冲区
CPU->>操作系统: IO请求完成中断
操作系统->>应用程序: read()返回
其中我们可以看到,因为IO是由内核态完成,所以数据需要先拷贝到内核,再拷贝到应用程序:
+----------+ +------------------------+
+ 用户态 + + 应用程序缓冲区 +
+----------+ +------------------------+
^
------------------------|--------------------
|CPU拷贝
+----------+ +----------+
+ 内核态 + + 内核缓冲区 +
+----------+ +----------+
^
------------------------|--------------------
|DMA拷贝
+----------+ +----------+
+ 硬件 + + 硬盘 +
+----------+ +----------+
内存映射(mmap)
那么可不可以避免这种无谓的拷贝呢?人们设计了内存映射(Memory-Mapped)机制来解决这个问题:
+----------+ +------------------------+
+ 用户态 + + 映射共享 | 应用程序缓冲区 +
+----------+ +------------------------+
^
------------------------|--------------------
v
+----------+ +----------+
+ 内核态 + + 内核缓冲区 +
+----------+ +----------+
^
------------------------|--------------------
|DMA拷贝
+----------+ +----------+
+ 硬件 + + 硬盘 +
+----------+ +----------+
通过将内核缓冲区与应用程序缓冲区互相映射,避免了CPU的拷贝。内存映射的实现原理涉及到操作系统的虚拟内存管理,我们通过一个图简单看一下:
graph TD
进程A_0xC0000000 --> 进程A_映射表 --> 物理内存_0x1000
进程A_0xC0001000 --> 进程A_映射表 --> 物理内存_0x3000
进程B_0xC0000000 --> 进程B_映射表 --> 物理内存_0x2000
进程B_0xC0001000 --> 进程B_映射表 --> 物理内存_0x3000
平时应用程序进程操作的地址都是虚拟地址,都有着独立的地址空间,所以如果你尝试再不同进程中打印地址的话,可能会得到相同的地址。这些地址通过一个映射表(页表)映射到物理内存(内存条真正的地址)。
当没有内存映射时,各个进程都是操作的不同的物理内存;当操作系统做了内存映射之后,会在映射进程的页表中,将两个进程的虚拟地址都指向同一个物理地址,从而实现内存的映射、共享。
内存映射对于单个文件的读取、写入(写入同理,也减少了一次拷贝),都取得了不错优化。例如文件下载服务器(读操作+写操作):
+----------+ +------------------------+
+ 用户态 + + 映射共享 | 文件服务器缓冲区 +
+----------+ +------------------------+
^
------------------------|-------------------
|-------------| CPU拷贝
v v
+----------+ +----------+ +-----------+
+ 内核态 + + 内核缓冲区 + +socket缓冲区+
+----------+ +----------+ +-----------+
^ |DMA拷贝
------------------------|-------------|------
|DMA拷贝 v
+----------+ +----------+ +----------+
+ 硬件 + + 硬盘 + + 网卡 +
+----------+ +----------+ +----------+
注意,socket的缓冲区也可以映射到用户的虚拟地址空间中,所以只需要3次拷贝:2次DMA拷贝,1次CPU拷贝(没有内存映射时,需要4次拷贝:2次DMA拷贝,2次CPU拷贝)。
sendfile()
还是文件下载(读操作+写操作)的场景,其实内存映射还不够好,因为
- 需要多次上下文切换(
context switch) - 还是用到CPU来拷贝了,大材小用
- 需要修改
页表(内存地址映射表)
所以人们提出了sendfile()系统调用。sendfile(out, in)可以简单理解为mmap(in)+writer(out),只是减少了一次上下文切换。并且在Linux内核版本2.6.33之前(之后支持了普通文件),out只支持socekt,应用场景很受限。于是再次升级,变为sendfile+DMA:
+----------+ +------------------------+
+ 用户态 + + 文件服务器 +
+----------+ +------------------------+
|sendfile
------------------------|-------------------
|-------------| CPU拷贝buffer元信息
v v
+----------+ +----------+ +-----------+
+ 内核态 + + 内核缓冲区 + +socket缓冲区+
+----------+ +----------+ +-----------+
^ |-----------|DMA gather 拷贝
------------------------|--------------|------
|DMA拷贝 v
+----------+ +----------+ +----------+
+ 硬件 + + 硬盘 + + 网卡 +
+----------+ +----------+ +----------+
从示意图我们可以看出来,现在只有两次数据拷贝了(实际上还有一次缓冲区的元信息拷贝,但是相比于数据拷贝,可以忽略不计)!
splice
sendfile()+DMA已经取得了非常好的优化,但是也有一些局限:
- 需要硬件支持
- 输入文件目前必须支持
mmap操作(例如socket不可以)
The in_fd argument must correspond to a file which supports mmap(2)-like operations (i.e., it cannot be a socket).
这限制了sendfile()的使用场景。于是人们提出了splice()来补充一些应用场景。splice()使用起来会有点复杂:
int pfd[2];
//创建管道
pipe(pfd);
//从文件读取,写入管道:只是创建管道指针,并不会真的拷贝数据
ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
//从管道读取,写入socket:从管道指针读取,直接写到网卡
bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
通过三次系统调用,实现了仅有两次的拷贝:
+----------+ +----------------------------------------------+
+ 用户态 + + 文件服务器 +
+----------+ +----------------------------------------------+
|②splice() |①pipe() |③splice()
------------------------|----------------|-----------------|------
| | |
v v v
+----------+ +----------+ +-----------+ +-----------+
+ 内核态 + + 内核缓冲区 + --> + pipe + --> +socket缓冲区+
+----------+ +----------+ +-----------+ +-----------+
^ |DMA拷贝
------------------------|----------------------------------|------
|DMA拷贝 v
+----------+ +----------+ +----------+
+ 硬件 + + 硬盘 + + 网卡 +
+----------+ +----------+ +----------+
总结
| 一次文件下载 | CPU拷贝 | DMA拷贝 | 系统调用 | 上下文切换 | 限制 |
|---|---|---|---|---|---|
| 传统方法 | 2 | 2 | 2 | 2 | |
| 内存映射 | 1 | 2 | 2 | 2 | |
| sendfile() | 1 | 2 | 1 | 1 | 输入文件必须支持mmap,socket不支持 |
| sendfile()+DMA | 0 | 2 | 1 | 1 | 输入文件必须支持mmap,socket不支持 |
| splice() | 0 | 2 | 3 | 3 | 必须通过管道 |
JVM视角的IO
Java进程即JVM,其实就是操作系统中的一个普通应用程序,当JVM实现了操作系统的一种系统调用,便支持了一种IO方式:
| 一次文件下载 | Java实现 |
|---|---|
| 传统方法 | 初始版本IO;NIO中的DirectBuffer |
| 内存映射 | NIO中的MappedByteBuffer |
| sendfile() | NIO中的FileChannel.transferTo() |
| splice() | - |
初始版本IO额外开销
Java初始版本IO,除去我们之前聊到的内核态与用户态之间的拷贝,还需要JVM native内存(用户态,下图黄色区域)拷贝到JAVA堆(用户态,下图白色区域)中。为什么需要这样呢?为什么不能直接从内核拷贝到JVM堆上的一块内存中呢? 因为Java的堆受到垃圾回收器的直接管理,如果IO发生在堆上,IO写入的过程中垃圾回收器可能会进行内存空间整理,这就可能导致IO写入的内存地址失效。所以,JNI(Java Native Inteface)在调用 IO 操作的 C 类库时,规定了写入时地址不能失效,这就导致了不能在 heap 上直接进行 IO 操作。当然,在IO操作的时候禁止 GC 也是一个选项,但是如果 IO 时间过长,那么则可能会引起堆空间溢出。
阅读JVM的源码 io_util.c的read_bytes方法 ,也可以验证我们的思考:
jint
readBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jfieldID fid)
{
// 精简了代码,只保留了主要逻辑
char stackBuf[BUF_SIZE];
char *buf = NULL;
if (len == 0) {
return 0;
} else if (len > BUF_SIZE) {
buf = malloc(len);
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return 0;
}
} else {
buf = stackBuf;
}
// buf 要么是JVM这个进程的native栈上的数组,要么是c程序通过malloc分配的,不是用户申请
...
if (fd == -1) {
...
} else {
nread = (jint)IO_Read(fd, buf, len);
if (nread > 0) {
// 从native`buf`拷贝数据到Java视角里的数组`bytes`
(*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);
} else if (nread == JVM_IO_ERR) {
JNU_ThrowIOExceptionWithLastError(env, "Read error");
} else if (nread == JVM_IO_INTR) {
JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL);
} else { /* EOF */
nread = -1;
}
}
/**
* 将buf数组中的值拷贝到jbyteArray数组中
* @param array: 拷贝数据目的地,java byte[] 类型
* @param start: jbyteArray数组的起始地址,从buf的首地址开始复制
* @param len: 要拷贝的数据长度
* @param buf: 拷贝数据源
*/
void (JNICALL *SetByteArrayRegion)(JNIEnv *env, jbyteArray array, jsize start, jsize len, jbyte *buf);
NIO
不出意外,早期版本的Java IO被人们吐槽,非常的慢(上文提到的额外的拷贝之外,IO缓存也存在问题)。于是Java迭代更新,提出了DirectBuffer便好理解了。DirectBuffer就是直接在JVM自用的内存区域(下图黄色区域)分配一块内存,相当于利用JNI直接malloc一块内存,不受垃圾回收器管理(1)。
更近一步地,MappedByteBuffer利用mmap实现了内存映射的DirectBuffer,支持了更多场景;FileChannel.transferTo()利用sendfile()实现了内核发送文件。
Java程序视角的IO
越是底层,IO的优化方式越是丰富,越是顶层,优化的范围越是局限,这是无法改变的事实。那么,Java程序除了尽量使用更高效的底层API,还可以做什么呢?我们来学习一些优秀开源项目的优化经验:
Flink shuffle 优化
Flink是一个分布式的流批一体的计算引擎(与Spark类似),shuffle操作作为常用的操作,可以简单理解为将进行数据分组(group by、repartition)。在批处理过程中,各个操作之间通过数据落盘来进行数据交换,涉及到大量的IO。为了优化这个过程的速度和稳定性(2),Flink做了如下工作:
- 压缩:将shuffle结果压缩,虽然增加了少量的CPU负担,但是可以减少硬盘与网络IO。
- 排序:原本Flink的shuffle基于hash的方式,将分组结果写入多个文件,既增加了写入时的随机IO,也增加了读取时的随机IO。Flink优化为基于排序的shuffle,将排序结果写入同一个文件(由于结果较大,可能是多段排序好的内容,并非全部有序),写入时只有顺序IO。读取时,通过IO调度,尽量减少随机IO。
- IO调度:将shuffle结果的读请求(同一个文件,不同的offset),按照offset排序,将随机IO尽量转化为顺序IO。
IO的未来
在肉眼可见的未来,IO速度跟CPU还是会有着数量级上的差异,但是各个IO设备之间的速度差距似乎在消失:
这是2020年的数据,通过回看历史,发现内网的网络速度逐渐变快,甚至赶上了硬盘的速度。这意味着计算与存储分离的硬件条件已经基本达到,这也是Spark、Flink推出
Remote Shuffle(”移动数据而非移动计算”)的原因。