21、高速 I / O(上)
前两节,我们介绍了 IO 类库和 NIO 类库,尽管在平时的业务开发中,我们很少会用到它们
但是对于一些常用中间件、基础系统,比如:Kakfa、RocketMQ、MySQL 等,其内部实现涉及大量的文件和网络等 I / O 读写操作
I / O 读写是否高效,直接决定了这些中间件和基础系统的性能,是优化的重中之重,I / O 读写的优化方式,也是面试中经常被问及的知识点
关于高速 I / O ,我们分两节来讲解
- 本节我们介绍普通 I / O 读写的底层实现原理,让你知道 I / O 读写慢在哪
- 下一节我们介绍提高 I / O 读写速度的方法,让你知道如何让它快
1、用户态和内核态
要了解 I / O 读写的底层实现原理,我们需要先了解两个非常重要的概念:内核态和用户态
对于软件的开发,分层是一种非常常见的设计思路,对于操作系统这种特殊软件来说也不例外
我们拿 Linux 操作系统举例:简单来讲,Linux 操作系统可以分为下图所示的这样几层
操作系统中包含计算机运行所需要的核心程序,这部分程序用来访问硬件资源,比如:调度 CPU、读写磁盘、网卡、内存等
我们把这部分程序叫做操作系统内核(简称内核),应用程序运行在操作系统之上
因为操作硬件资源非常容易出错,并且一旦出错,错误将非常严重,大部分情况下都会导致计算机宕机,所以操作系统不允许应用程序直接访问硬件资源(比如读写磁盘)
如果应用程序需要访问硬件资源,那么只能通过操作系统提供的 API 来实现,我们把操作系统提供的这些 API 称为系统调用
系统调用比较底层,使用起来不够方便,于是 Linux 操作系统在此之上又提供了库函数
比如 Glibc 库、Posix 库,对系统调用进行封装,提供更加简单易用的函数,供应用程序开发使用
比如 Glibc 中的 malloc() 函数底层封装了 sbrk() 系统调用,fread()、fwrite() 函数底层封装了 read()、write() 系统调用
在开发应用程序时,我们既可以使用库函数,也可以直接使用系统调用
比如对于内存分配,我们一般使用 malloc() 库函数,对于文件读写,我们一般直接使用 read()、write() 系统调用
除此之外,Linux 操作系统还提供了 Shell 这一特别的程序,也就是我们平时所说的命令行
Shell 让我们能够在不进行编程的情况下,通过在命令行中运行 Shell 命令或脚本,达到访问硬件的目的,比如使用 cp 拷贝文件,使用 rm 删除文件等
为了避免应用程序在运行时,访问到内核所用的内存空间,操作系统将虚拟内存空间分为内核空间和用户空间两部分
而我们经常提到的内核态和用户态,实际上指的是 CPU 所处的状态
当 CPU 执行内核程序时,CPU 进入内核态,在内核态下:CPU 拥有最高权限,可以执行所有的机器指令,当然,也可以访问硬件设备
当 CPU 执行应用程序时,CPU 进入用户态,在用户态下:CPU 权限被限制,只能执行部分机器指令,因此无法访问硬件设备
除此之外,CPU 在内核态下,可以访问所有的虚拟内存空间,包括用户空间和内核空间,在用户态下,只能访问用户空间,不能访问内核空间
2、系统调用与上下文切换
当应用程序调用操作系统的系统调用时,CPU 从用户态切换到内核态,当系统调用执行完成之后,CPU 又从内核态切换到用户态,我们把这种状态的切换叫做上下文切换
实际上,上下文切换是一个比较宽泛的概念,在很多场景中的都会用到
比如线程切换也会引起上下文切换,对于线程引起的上下文切换,我们在多线程模块讲解,本节我们聚焦在内核态与用户态切换引起的上下文切换上
对于普通函数调用来说,应用程序只需要将局部变量、参数、返回地址等信息存入函数调用栈,并同步保存和修改一些寄存器即可
比如 SP、BP 寄存器,函数调用产生的额外耗时相对较少
尽管系统调用从本质上也是一种函数调用,但相比于应用程序内的普通函数调用来说,系统调用要慢很多,耗时的地方主要在于内核态与用户态的上下文切换
详细来讲,主要有以下两方面
- 寄存器保存与恢复耗时
对于系统调用来说,因为操作系统内核是不信赖应用程序的,所以操作系统内核不会使用应用程序开辟的位于用户空间的函数调用栈
操作系统会在内核空间中分配新的函数调用栈,供内核程序执行的过程中使用
当调用系统调用时,从用户空间的函数调用栈切换到内核空间的函数调用栈,操作系统需要更新更多跟栈相关的寄存器,比如 SS 栈基址寄存器
除此之外,因为应用程序的代码和内核程序的代码所存储的位置也不同,所以当从应用程序代码切换到内核代码时,CS 代码段基址寄存器也需要更新
并且在更新之前,操作系统需要将这些寄存器原来的值保存下来,以便重新切换回用户态之后恢复执行 - 缓存失效带来的性能损耗
根据局部性原理,CPU 有 L1、L2、L3 三级 Cache,用于缓存将要执行的代码以及所需的内存数据
应用程序的代码以及所需的数据存储在用户空间,内核代码以及所需的数据存储在内核空间,显然不是相邻的
因此用户态到内核态的转化会导致 CPU 缓存失效
3、I / O 读写的底层实现原理
了解了用户态、内核态、系统调用、上下文切换这些基础概念之后,我们来再来看下 I / O 读写的底层实现原理
尽管 Java I / O 类库(java.io 和 java.nio)使用起来比较简单,但其底层实现原理却比较复杂
Java 作为一个跨平台的语言,为了屏蔽操作系统的差异,提供了统一的 Java I / O 类库,在不同操作系统下,Java I / O 类库中的函数底层调用不同的系统调用和库函数来实现
在 Linux 操作系统下,Java I / O 类库中的 read()、write() 函数,底层通过调用 Linux 操作系统的 read()、write() 系统调用来实现
因此使用 Java 中的 read()、write() 函数进行 I / O 读写,势必会涉及用户态和内核态的切换,I / O 读写流程如下图所示
实际上,上图只包含 I / O 读写的过程,还缺少一些必要的环节
在进行 I / O 读写之前,我们需要先建立与 I / O 设备的连接
在 Linux 操作系统下,一切皆文件,I / O 设备也不例外,Linux 操作系统会为每个与 I / O 设备建立的连接,分配一个文件描述符(file descriptor)
对应到代码层面,当我们调用 open() 系统调用建立连接时,open() 系统调用会将连接对应的文件描述符作为返回值返回,后续对文件描述符的读写就等价于对 I / O 设备的读写
操作系统为每个文件描述符都分配一个读缓冲区和一个写缓冲区,分别对应到图中的内核读缓冲区和内核写缓冲区
内核读写缓冲区只有在第一次被使用(调用 read() 或 write() 系统调用)时,才会真正被分配内存空间
默认读缓冲区的大小一般为 8192 字节,写缓冲区的大小一般为 16384 字节,当然我们也可以根据业务需求,通过系统调用,来重新设置内核读写缓冲区的大小
因为文件描述会附带一些信息存储在内存中,并且每个文件描述符都分配有内核缓冲区,会占用一定的存储空间
所以在读写完成之后,应用程序需要调用 close() 系统调用,释放文件描述符及其内核缓冲区
3.1、示例
我们拿文件读写举例,示例代码如下所示,代码中的 buffer 就是上图中的应用程序缓冲区
// Linux 下读写文件的 C 语言代码实现
#include <stdio.h>
#include <fcntl.h>
int main(int argc, char *argv[]) {
int rfd;
int wfd;
char rFile[] = "F:\\test-file\\in.txt";
char wFile[] = "F:\\test-file\\out.txt";
char buffer[256]; // 应用程序缓冲区
rfd = open(rFile, O_RDONLY, 0666);
wfd = open(wFile, O_CREAT | O_WRONLY, 0666);
if (rfd < 0 || wfd < 0) {
printf("open file failed!\n");
return -1;
}
int len;
while ((len = read(rfd, buffer, 255)) > 0) {
write(wfd, buffer, len);
}
close(rfd);
close(wfd);
return 0;
}
接下来,我们重点剖析一下 I / O 读写流程
3.2、读操作流程
当应用程序调用 Java I / O 类库中的 read() 函数时,read() 函数会调用操作系统的 read() 系统调用
操作系统会先检查内核读缓冲区中有没有足够的数据,如果有足够数据,那么就直接将数据从内核读缓冲区拷贝到应用程序缓冲区
否则,操作系统将从磁盘中读取数据,拷贝到内核读缓冲区中,然后再拷贝到应用程序缓冲区,之后 read() 函数便返回
3.3、写操作流程
当应用程序调用 Java I / O 类库中的 write() 函数时,write() 函数会调用操作系统的 write() 系统调用
操作系统会先将应用程序缓冲区中的数据,拷贝到内核写缓冲区
这个时候,对于应用程序来说,写操作就完成了,write() 函数便返回
操作系统会根据一定的规则,比如写缓冲满了或到达了一定时间间隔,在某个时刻,将写缓冲区中的数据,一并写入 I / O 设备
如果我们希望 write() 返回之后,数据立刻存储到磁盘,那么需要显式地调用强制落盘函数,比如使用 Linux 操作系统下的 sync() 系统调用
当然,如果我们调用了 close() 系统调用,就不需要再调用 sync() 系统调用了
这是因为调用 close() 系统调用会释放内核缓冲区,为了防止数据丢失,调用 close() 系统调用会默认自动调动 sync() 系统调用,将内核缓冲区中的数据写入 I / O 设备
3.4、读写流程总结
在上述读写流程中,读写数据均需要进行 2 次数据拷贝
我们拿读取来举例,当调用 read() 函数读取数据时,数据从 I / O 设备拷贝到内核缓冲区,再从内核缓冲区拷贝到应用程序缓冲区,总共发生了 2 次数据拷贝
既然操作系统内核既可以访问内核空间,又可以访问用户空间,那么它为什么不直接将数据从 I / O 设备拷贝到应用程序缓冲区呢?如果这样做,不就能减少了一次数据拷贝吗?
这是因为,应用程序缓冲区是应用程序维护的,应用程序对其有主宰权,内核代码无法控制其大小和生命周期
出于稳妥起见,操作系统为 I / O 设备在内核空间申请了内核缓冲区,用于缓存从 I / O 设备中读取的数据,进而减少与 I / O 设备的交互次数
3.5、用户态缓存
那么问题又来了,既然已经有了内核缓冲区为 I / O 设备提供数据缓存功能,为什么 Java I / O 类库还提供支持缓存功能的 BufferedInputStream、BufferedOutputStream 类呢?
BufferedInputStream 和 BufferedOutputStream 的作用类似,我们拿 BufferedInputStream 举例讲解
BufferedInputStream 相当于在用户空间又增加一层缓存,当应用程序调用 read() 函数读取数据时,会先从位于用户空间的缓存中读取,如果缓存中无数据可读,这时才会调用 Linux 操作系统的 read() 系统调用
增加一层用户空间的缓存,可以减少系统调用的次数,我们知道,系统调用会导致上下文切换,上下文切换是比较耗时的,所以减少系统调用也会提高 read() 函数的性能
4、CPU 减负神器之 DMA 技术
从 I / O 读写的底层实现原理,我们可以发现,在 I / O 读写过程中,CPU 一直参与其中,负责 I / O 设备与内核缓冲区之间,以及内核缓冲区与应用程序缓冲区之间的数据拷贝
而 CPU 最擅长的是运算,比如加法运算、位运算,让 CPU 去做拷贝数据这种简单工作(将二进制位 0 或 1 从一个存储单元移动到另一个存储单元),实际上是大材小用
除此之外,相对于 CPU 来说,像硬盘、网卡等 I / O 设备的读写速度非常慢,在 I / O 读写的过程中,CPU 会一直被占用,无法去处理其他事情,无疑是非常浪费 CPU 资源的
于是,科学家们便发明了 DMA(Direct Memory Access)技术
通过在主板上安装一个叫做 DMAC(DMA Controller,DMA 控制器)的协处理器(或叫芯片),协助 CPU 来完成 I / O 设备的数据读写工作
随着计算机的发展,安装在计算机上的 I / O 设备越来越多,仅在主板上安装一个通用的 DMAC 已经远远不够了
因此现在很多 I / O 设备都自带 DMAC,比如硬盘、网卡、显示器都有各自的 DMAC
具体来讲,DMA 是怎样工作的呢?
- 当调用 read() 函数从 I / O 设备读取数据时,通过系统调用,CPU 会进入内核态,发送 I / O 请求到 DMAC,告知 DMAC 从 I / O 设备中读取哪些数据到哪块内存
之后 CPU 便去做其他事情,由 DMAC 来完成将数据从 I / O 设备拷贝到内核缓冲区的工作
当 DMAC 完成之后,通过中断,通知 CPU 内核缓冲区中的数据已经准备就绪,然后 CPU 再将内核读缓冲区中的数据拷贝到应用程序缓冲区 - 当调用 write() 函数将数据写入 I / O 设备时,通过系统调用,CPU 会进入内核态,将数据从应用程序缓冲区拷贝到内核缓冲区
然后发送 I / O 请求给 DMAC,告知将哪块内存中的数据拷贝到 I / O 设备中
之后 CPU 便去做其他事情了,由 DMAC 来完成将数据从内核写缓冲区中拷贝到 I / O 设备中的工作
如下图所示,通过 DMA 技术,不管是读取 I / O 数据还是写入 I / O 数据,CPU 只需要参与一次数据拷贝,I / O 操作不再占用大量的 CPU 资源,CPU 利用率提高
不过,你可能会说,DMA 技术只不过是对 CPU "减负" 而已,由 CPU 拷贝换成了 DMA 拷贝,貌似并不能提高 I / O 读写速度呀?
实际上数据拷贝是非常简单的,利用通用的 CPU 来进行数据拷贝,反倒没有使用 "专项专用" 的 DMA 高效,毕竟针对数据拷贝,DMA 可以做大量的优化,让性能达到极致
DMA 技术可以提高 I / O 读写速度,目前大部分的计算机都已经支持,调用 read()、write() 函数进行普通的 I / O 读写,底层已经在使用 DMA 技术了
因此对于我们应用程序开发者来说,DMA 技术是无感的,这也是为什么我把 DMA 放到这一节而不是下一节讲解的原因
5、课后思考题
既然 DMA 技术能够给 CPU "减负",那么为什么不让它也负责内核缓冲区和应用程序缓冲区之间的数据拷贝呢?这样减负效果不是更好吗?
磁盘等 I / O 设备的读写速度比较慢,DMA 能够显著为 CPU 减负
内存缓冲区和应用程序缓冲区均在内存中,读写速度相较于 I / O 设备要快很多,没有必要单独设计 DMA
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17471165.html