IO
# JavaIO流
# 题目描述
面试官:谈一下IO流
# 解题思路
从字符流和字节流两方面回答包括输入输出以及两者的对比
# 数据流的基本概念
几乎所有的程序都离不开信息的输入和输出,比如从键盘读取数据,从文件中获取或者向文件中存入数据,在显示器上显示数据。这些情况下都会涉及有关输入/输出的处理。
在Java中,把这些不同类型的输入、输出源抽象为流(Stream),其中输入或输出的数据称为数据流(Data Stream),用统一的接口来表示。
# IO 流的分类
数据流是指一组有顺序的、有起点和终点的字节集合。
按照流的流向分,可以分为输入流和输出流。注意:这里的输入、输出是针对程序来说的。
输出:把程序(内存)中的内容输出到磁盘、光盘等存储设备中。
输入:读取外部数据(磁盘、光盘等存储设备的数据)到程序(内存)中。
按处理数据单位不同分为字节流和字符流。字节流:每次读取(写出)一个字节,当传输的资源文件有中文时,就会出现乱码。
字符流:每次读取(写出)两个字节,有中文时,使用该流就可以正确传输显示中文。
1字符 = 2字节; 1字节(byte) = 8位(bit); 一个汉字占两个字节长度。
按照流的角色划分为节点流和处理流。节点流:从或向一个特定的地方(节点)读写数据。如
FileInputStream。
处理流(包装流):是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写。如BufferedReader。处理流的构造方法总是要带一个其他的流对象做参数。一个流对象经过其他流的多次包装,称为流的链接。
注意:一个IO流可以既是输入流又是字节流又或是以其他方式分类的流类型,是不冲突的。比如FileInputStream,它既是输入流又是字节流还是文件节点流。
Java IO 流有4个抽象基类:
其他流都是继承于这四大基类的。下图是Java IO 流的整体架构图:
知道了 IO 流有这么多分类,那我们在使用的时候应该怎么选择呢?比如什么时候用输出流?什么时候用字节流?可以根据下面三步选择适合自己的流:
首先自己要知道是选择输入流还是输出流。这就要根据自己的情况决定,如果想从程序写东西到别的地方,那么就选择输入流,反之就选输出流;然后考虑你传输数据时,是每次传一个字节还是两个字节,每次传输一个字节就选字节流,如果存在中文,那肯定就要选字符流了。通过前面两步就可以选出一个合适的节点流了,比如字节输入流 InputStream,如果要在此基础上增强功能,那么就在处理流中选择一个合适的即可。
# 字节输入流 InputStream
java.io 包下所有的字节输入流都继承自 InputStream,并且实现了其中的方法。InputStream 中提供的主要数据操作方法如下:
- int read():从输入流中读取一个字节的二进制数据。
- int read(byte[] b):将多个字节读到数组中,填满整个数组。
- int read(byte[] b, int off, int len):从输入流中读取长度为 len 的数据,从数组 b 中下标为off 的位置开始放置读入的数据,读完返回读取的字节数。
- void close():关闭数据流。
- int available():返回目前可以从数据流中读取的字节数(但实际的读操作所读得的字节数可能大于该返回值)。
- long skip(long l):跳过数据流中指定数量的字节不读取,返回值表示实际跳过的字节数。对数据流中字节的读取通常是按从头到尾顺序进行的,如果需要以反方向读取,则需要使用回推(Push Back)操作。
在支持回推操作的数据流中经常用到如下几个方法:
- boolean markSupported():用于测试数据流是否支持回推操作,当一个数据流支持 mark() 和 reset()方法时,返回 true,否则返回 false。
- void mark(int readlimit):用于标记数据流的当前位置,并划出一个缓冲区,其大小至少为指定参数的大小。
- void reset():将输入流重新定位到对此流最后调用mark() 方法时的位置。
字节输入流InputStream 有很多子类,日常开发中,经常使用的一些类见下图:
- ByteArrayInputStream:字节数组输入流,该类的功能就是从字节数组 byte[] 中进行以字节为单位的读取,也就是将资源文件都以字节形式存入到该类中的字节数组中去,我们拿数据也是从这个字节数组中拿。
- PipedInputStream:管道字节输入流,它和 PipedOutputStream 一起使用,能实现多线程间的管道通信。
- FilterInputStream:装饰者模式中充当装饰者的角色,具体的装饰者都要继承它,所以在该类的子类下都是用来装饰别的流的,也就是处理类。
- BufferedInputStream:缓冲流,对处理流进行装饰、增强,内部会有一个缓冲区,用来存放字节,每次都是将缓冲区存满然后发送,而不是一个字节或两个字节这样发送,效率更高。
- DataInputStream:数据输入流,用来装饰其他输入流,它允许通过数据流来读写Java基本类型。
- FileInputStream:文件输入流,通常用于对文件进行读取操作。
- File:对指定目录的文件进行操作。
- ObjectInputStream:对象输入流,用来提供对“基本数据或对象”的持久存储。通俗点讲,就是能直接传输Java对象(序列化、反序列化用)。
下面通过一个例子讲解InputStream中常用的方法的使用:
# 字节输出流 OutputStream
与字节输入流类似,java.io 包下所有字节输出流大多是从抽象类 OutputStream 继承而来的。
OutputStream 提供的主要数据操作方法:
- void write(int i):将字节 i 写入到数据流中,它只输出所读入参数的最低 8 位,该方法是抽象方法,需要在其输出流子类中加以实现,然后才能使用。
- void write(byte[] b):将数组 b 中的全部 b.length 个字节写入数据流。
- void write(byte[] b, int off, int len):将数组 b 中从下标 off 开始的 len 个字节写入数据
流。元素 b[off] 是此操作写入的第一个字节,b[off + len - 1] 是此操作写入的最后一个字节。
- voidclose():关闭输出流。
- void flush():刷新此输出流并强制写出所有缓冲的输出字节。为了加快数据传输速度,提高数据输出效率,又是输出数据流会在提交数据之前把所要输出的数据先暂时保存在内存缓冲区中,然后成批进行输出,每次传输过程都以某特定数据长度为单位进行传输,在这种方式下,数据的末尾一般都会有一部分数据由于数量不够一个批次,而存留在缓冲区里,调用 flush() 方法可以将这部分数据强制提交。
IO 中输出字节流的继承图可见下图:
它们的作用可以参考上面字节输入流中的各个子类的介绍,这里不再赘述。
下面通过一个例子讲解OutputStream中常用的方法的使用:
下面展示一个字节输入流和字节输出流综合使用的案例:复制文件。
# 字符流
# 字符流
从JDK1.1开始,java.io 包中加入了专门用于字符流处理的类,它们是以Reader和Writer为基础派生的一系列类。
同其他程序设计语言使用ASCII字符集不同,Java使用Unicode字符集来表示字符串和字符。ASCII字符集以一个字节(8bit)表示一个字符,可以认为一个字符就是一个字节(byte)。但Java使用的Unicode是一种大字符集,用两个字节(16bit)来表示一个字符,这时字节与字符就不再相同。为了实现与其他程序语言及不同平台的交互,Java提供一种新的数据流处理方案,称作读者(Reader)和写者(Writer)。
# 字符输入流 Reader
Reader是所有的输入字符流的父类,它是一个抽象类。
Reader及其一些常用子类:
- CharReader和SringReader是两种基本的介质流,它们分别将Char数组、String中读取数据。
- PipedReader 是从与其它线程共用的管道中读取数据。
- BufferedReader很明显是一个装饰器,它和其他子类负责装饰其他Reader对象。
- FilterReader是所有自定义具体装饰流的父类,其子类PushBackReader对Reader对象进行装饰,会增加一个行号。
- InputStreamReader是其中最重要的一个,用来在字节输入流和字符输入流之间作为中介,可以将字节输入流转换为字符输入流。
- FileReader 可以说是一个达到此功能、常用的工具类,在其源代码中明显使用了将FileInputStream 转变为Reader 的方法。
Reader 中各个类的用途和使用方法基本和InputStream 中的类使用一致。
# 字符输出流 Writer
Writer是所有的输出字符流的父类,它是一个抽象类。
Writer及其一些常用子类:
- CharWriter、StringWriter 是两种基本的介质流,它们分别向Char 数组、String 中写入数据。
- PipedWriter 是向与其它线程共用的管道中写入数据。BufferedWriter 是一个装饰器为Writer 提供缓冲功能。
- PrintWriter 和PrintStream 极其类似,功能和使用也非常相似。OutputStreamWriter是其中最重要的一个,用来在字节输出流和字符输出流之间作为中介,可以将字节输出流转换为字符输出流。
- FileWriter 可以说是一个达到此功能、常用的工具类,在其源代码中明显使用了将OutputStream转变为Writer 的方法。
Writer 中各个类的用途和使用方法基本和OutputStream 中的类使用一致。
下面展示一个字符输入流和字符输出流综合使用的案例:复制文件。
# 字节流与字符流的区别
1、要把一片二进制数据数据逐一输出到某个设备中,或者从某个设备中逐一读取一片二进制数据,不管输入输出设备是什么,我们要用统一的方式来完成这些操作,用一种抽象的方式进行描述,这个抽象描述方式起名为IO流,对应的抽象类为OutputStream和InputStream ,不同的实现类就代表不同的输入和输出设备,它们都是针对字节进行操作的。
2、在应用中,经常要完全是字符的一段文本输出去或读进来,用字节流可以吗?计算机中的一切最终都是二进制的字节形式存在。对于“中国”这些字符,首先要得到其对应的字节,然后将字节写入到输出流。读取时,首先读到的是字节,可是我们要把它显示为字符,我们需要将字节转换成字符。由于这样的需求很广泛,人家专门提供了字符流的包装类。
3、底层设备永远只接受字节数据,有时候要写字符串到底层设备,需要将字符串转成字节再进行写入。字符流是字节流的包装,字符流则是直接接受字符串,它内部将串转成字节,再写入底层设备,这为我们向IO设别写入或读取字符串提供了一点点方便。
4、字符向字节转换时,要注意编码的问题,因为字符串转成字节数组,其实是转成该字符的某种编码的字节形式,读取也是反之的道理。
字节流在操作时本身不会用到缓冲区(内存),是文件本身直接操作的;而字符流在操作时使用了缓冲区,通过缓冲区再操作文件。
# 字节流与字符流的使用场景
字节流一般用来处理图像,视频,以及PPT,Word类型的文件。字符流一般用于处理纯文本类型的文件,如TXT文件等。字节流可以用来处理纯文本文件,但是字符流不能用于处理图像视频等非文本类型的文件。
# 如何实现零拷贝
# 前⾔
磁盘可以说是计算机系统最慢的硬件之⼀,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术⾮常的多,⽐如零拷⻉、直接 I/O、异步 I/O 等等,这些优化的⽬的就是为了提⾼系统的吞吐量,另外操作系统内核中的磁盘⾼速缓存区,可以有效的减少磁盘的访问次数。
这次,我们就以「⽂件传输」作为切⼊点,来分析 I/O ⼯作⽅式,以及如何优化传输⽂件的性能。
# 正⽂
# 1.为什么要有 DMA 技术?
在没有 DMA 技术前,I/O 的过程是这样的:
- CPU 发出对应的指令给磁盘控制器,然后返回;
- 磁盘控制器收到指令后,于是就开始准备数据,会把数据放⼊到磁盘控制器的内部缓冲区中,然后产⽣⼀个中断;
- CPU 收到中断信号后,停下⼿头的⼯作,接着把磁盘控制器的缓冲区的数据⼀次⼀个字节地读进⾃⼰的寄存器,然后再把寄存器⾥的数据写⼊到内存,⽽在数据传输的期间 CPU 是⽆法执⾏其他任务的。
为了⽅便你理解,我画了⼀副图:
可以看到,整个数据的传输过程,都要需要 CPU 亲⾃参与搬运数据的过程,⽽且这个过程,CPU 是不能做其他事情的。
简单的搬运⼏个字符数据那没问题,但是如果我们⽤千兆⽹卡或者硬盘传输⼤量数据的时候,都⽤ CPU来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。
什么是 DMA 技术?简单理解就是,在进⾏ I/O 设备和内存的数据传输的时候,数据搬运的⼯作全部交给 DMA 控制器,⽽ CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
那使⽤ DMA 控制器进⾏数据传输的过程究竟是什么样的呢?下⾯我们来具体看看。
具体过程:
- ⽤户进程调⽤ read ⽅法,向操作系统发出 I/O 请求,请求读取数据到⾃⼰的内存缓冲区中,进程进⼊阻塞状态;
- 操作系统收到请求后,进⼀步将 I/O 请求发送 DMA,然后让 CPU 执⾏其他任务;
- DMA 进⼀步将 I/O 请求发送给磁盘;
- 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知⾃⼰缓冲区已满;
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷⻉到内核缓冲区中,此时不占⽤ CPU,CPU 可以执⾏其他任务;
- 当 DMA 读取了⾜够多的数据,就会发送中断信号给 CPU;
- CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷⻉到⽤户空间,系统调⽤返回;
可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的⼯作,⽽是全程由 DMA 完成,但是 CPU在这个过程中也是必不可少的,因为传输什么数据,从哪⾥传输到哪⾥,都需要 CPU 来告诉 DMA 控制器。
早期 DRM 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备⾥⾯都有⾃⼰的 DMA 控制器。
# 2.传统的⽂件传输有多糟糕?
如果服务端要提供⽂件传输的功能,我们能想到的最简单的⽅式是:将磁盘上的⽂件读取出来,然后通过⽹络协议发送给客户端。
传统 I/O 的⼯作⽅式是,数据读取和写⼊是从⽤户空间到内核空间来回复制,⽽内核空间的数据是通过操作系统层⾯的 I/O 接⼝从磁盘读取或写⼊。
代码通常如下,⼀般会需要两个系统调⽤:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
代码很简单,虽然就两⾏代码,但是这⾥⾯发⽣了不少的事情。
⾸先,期间共发⽣了 4 次⽤户态与内核态的上下⽂切换,因为发⽣了两次系统调⽤,⼀次是 read() ,⼀次是 write() ,每次系统调⽤都得先从⽤户态切换到内核态,等内核完成任务后,再从内核态切换回⽤户态。
上下⽂切换到成本并不⼩,⼀次切换需要耗时⼏⼗纳秒到⼏微秒,虽然时间看上去很短,但是在⾼并发的场景下,这类时间容易被累积和放⼤,从⽽影响系统的性能。
其次,还发⽣了 4 次数据拷⻉,其中两次是 DMA 的拷⻉,另外两次则是通过 CPU 拷⻉的,下⾯说⼀下这个过程:
- 第⼀次拷⻉ ,把磁盘上的数据拷⻉到操作系统内核的缓冲区⾥,这个拷⻉的过程是通过 DMA 搬运的。
- 第⼆次拷⻉ ,把内核缓冲区的数据拷⻉到⽤户的缓冲区⾥,于是我们应⽤程序就可以使⽤这部分数据了,这个拷⻉到过程是由 CPU 完成的。
- 第三次拷⻉ ,把刚才拷⻉到⽤户的缓冲区⾥的数据,再拷⻉到内核的 socket 的缓冲区⾥,这个过程依然还是由 CPU 搬运的。
- 第四次拷⻉ ,把内核的 socket 缓冲区⾥的数据,拷⻉到⽹卡的缓冲区⾥,这个过程⼜是由 DMA 搬运的。
我们回过头看这个⽂件传输的过程,我们只是搬运⼀份数据,结果却搬运了 4 次,过多的数据拷⻉⽆疑会消耗 CPU 资源,⼤⼤降低了系统性能。
这种简单⼜传统的⽂件传输⽅式,存在冗余的上⽂切换和数据拷⻉,在⾼并发系统⾥是⾮常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以**,要想提⾼⽂件传输的性能,就需要减少「⽤户态与内核态的上下⽂切换」和「内存拷⻉」的次数。**
# 3.如何优化⽂件传输的性能?
先来看看,如何减少「⽤户态与内核态的上下⽂切换」的次数呢?
读取磁盘数据的时候,之所以要发⽣上下⽂切换,这是因为⽤户空间没有权限操作磁盘或⽹卡,内核的权限最⾼,这些操作设备的过程都需要交由操作系统内核来完成,所以⼀般要通过内核去完成某些任务的时候,就需要使⽤操作系统提供的系统调⽤函数。
⽽⼀次系统调⽤必然会发⽣ 2 次上下⽂切换:⾸先从⽤户态切换到内核态,当内核执⾏完任务后,再切换回⽤户态交由进程代码执⾏。
所以,要想减少上下⽂切换到次数,就要减少系统调⽤的次数。
再来看看,如何减少「数据拷⻉」的次数?
在前⾯我们知道了,传统的⽂件传输⽅式会历经 4 次数据拷⻉,⽽且这⾥⾯,「从内核的读缓冲区拷⻉到⽤户的缓冲区⾥,再从⽤户的缓冲区⾥拷⻉到 socket 的缓冲区⾥」,这个过程是没有必要的。
因为⽂件传输的应⽤场景中,在⽤户空间我们并不会对数据「再加⼯」,所以数据实际上可以不⽤搬运到⽤户空间,因此⽤户的缓冲区是没有必要存在的。
# 4.如何实现零拷⻉?
零拷⻉技术实现的⽅式通常有 2 种:
- mmap + write
- sendfile
下⾯就谈⼀谈,它们是如何减少「上下⽂切换」和「数据拷⻉」的次数。
# mmap + write
在前⾯我们知道, read() 系统调⽤的过程中会把内核缓冲区的数据拷⻉到⽤户的缓冲区⾥,于是为了减少这⼀步开销,我们可以⽤ mmap() 替换 read() 系统调⽤函数。
buf = mmap(file, len);
write(sockfd, buf, len);
mmap() 系统调⽤函数会直接把内核缓冲区⾥的数据「映射」到⽤户空间,这样,操作系统内核与⽤户空间就不需要再进⾏任何的数据拷⻉操作。
具体过程如下:
- 应⽤进程调⽤了 mmap() 后,DMA 会把磁盘的数据拷⻉到内核的缓冲区⾥。接着,应⽤进程跟操作系统内核「共享」这个缓冲区;
- 应⽤进程再调⽤ write() ,操作系统直接将内核缓冲区的数据拷⻉到 socket 缓冲区中,这⼀切都发⽣在内核态,由 CPU 来搬运数据;
- 最后,把内核的 socket 缓冲区⾥的数据,拷⻉到⽹卡的缓冲区⾥,这个过程是由 DMA 搬运的。
我们可以得知,通过使⽤ mmap() 来代替 read() , 可以减少⼀次数据拷⻉的过程。
但这还不是最理想的零拷⻉,因为仍然需要通过 CPU 把内核缓冲区的数据拷⻉到 socket 缓冲区⾥,⽽且仍然需要 4 次上下⽂切换,因为系统调⽤还是 2 次。
# sendfile
在 Linux 内核版本 2.1 中,提供了⼀个专⻔发送⽂件的系统调⽤函数 sendfile() ,函数形式如下:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是⽬的端和源端的⽂件描述符,后⾯两个参数是源端的偏移量和复制数据的⻓度,返回值是实际复制数据的⻓度。
⾸先,它可以替代前⾯的 read() 和 write() 这两个系统调⽤,这样就可以减少⼀次系统调⽤,也就减少了 2 次上下⽂切换的开销。
其次,该系统调⽤,可以直接把内核缓冲区⾥的数据拷⻉到 socket 缓冲区⾥,不再拷⻉到⽤户态,这样就只有 2 次上下⽂切换,和 3 次数据拷⻉。如下图:
但是这还不是真正的零拷⻉技术,如果⽹卡⽀持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进⼀步减少通过 CPU 把内核缓冲区⾥的数据拷⻉到 socket缓冲区的过程。
你可以在你的 Linux 系统通过下⾯这个命令,查看⽹卡是否⽀持 scatter-gather 特性:
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on
于是,从 Linux 内核 2.4 版本开始起,对于⽀持⽹卡⽀持 SG-DMA 技术的情况下, sendfile() 系统调⽤的过程发⽣了点变化,具体过程如下:
- 第⼀步,通过 DMA 将磁盘上的数据拷⻉到内核缓冲区⾥;
- 第⼆步,缓冲区描述符和数据⻓度传到 socket 缓冲区,这样⽹卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷⻉到⽹卡的缓冲区⾥,此过程不需要将数据从操作系统内核缓冲区拷⻉到socket 缓冲区中,这样就减少了⼀次数据拷⻉;
所以,这个过程之中,只进⾏了 2 次数据拷⻉,如下图:
这就是所谓的零拷⻉(Zero-copy)技术,因为我们没有在内存层⾯去拷⻉数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进⾏传输的。
零拷⻉技术的⽂件传输⽅式相⽐传统⽂件传输的⽅式,减少了 2 次上下⽂切换和数据拷⻉次数,只需要2 次上下⽂切换和数据拷⻉次数,就可以完成⽂件的传输,⽽且 2 次的数据拷⻉过程,都不需要通过CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷⻉技术可以把⽂件传输的性能提⾼⾄少⼀倍以上。
# 使⽤零拷⻉技术的项⽬
事实上,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() 系统调⽤函数。
曾经有⼤佬专⻔写过程序测试过,在同样的硬件条件下,传统⽂件传输和零拷拷⻉⽂件传输的性能差异,你可以看到下⾯这张测试数据图,使⽤了零拷⻉能够缩短 65% 的时间,⼤幅度提升了机器传输数据的吞吐量。
另外,Nginx 也⽀持零拷⻉技术,⼀般默认是开启零拷⻉技术,这样有利于提⾼⽂件传输的效率,是否开启零拷⻉技术的配置如下:
http {
...
sendfile on
...
}
sendfile 配置的具体意思:
- 设置为 on 表示,使⽤零拷⻉技术来传输⽂件:sendfile ,这样只需要 2 次上下⽂切换,和 2 次数据拷⻉。
- 设置为 off 表示,使⽤传统的⽂件传输技术:read + write,这时就需要 4 次上下⽂切换,和 4 次数据拷⻉。
当然,要使⽤ sendfile,Linux 内核版本必须要 2.1 以上的版本。
# 5.PageCache 有什么作⽤?
回顾前⾯说道⽂件传输过程,其中第⼀步都是先需要先把磁盘⽂件数据拷⻉「内核缓冲区」⾥,这个「内核缓冲区」实际上是磁盘⾼速缓存(PageCache)。
由于零拷⻉使⽤了 PageCache 技术,可以使得零拷⻉进⼀步提升了性能,我们接下来看看 PageCache是如何做到这⼀点的。
读写磁盘相⽐读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘⾥的数据搬运到内存⾥,这样就可以⽤读内存替换读磁盘。
但是,内存空间远⽐磁盘要⼩,内存注定只能拷⻉磁盘⾥的⼀⼩部分数据。
那问题来了,选择哪些磁盘数据拷⻉到内存呢?
我们都知道程序运⾏的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很⾼,于是我们可以⽤ PageCache 来缓存最近被访问的数据,当空间不⾜时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
还有⼀点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是⾮常耗时的,为了降低它的影响,PageCache 使⽤了「预读功能」。
⽐如,假设 read ⽅法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后⾯的 32~64 KB 也读取到 PageCache,这样后⾯读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就⾮常⼤。
所以,PageCache 的优点主要是两个:
- 缓存最近被访问的数据;
- 预读功能;
这两个做法,将⼤⼤提⾼读写磁盘的性能。
但是,在传输⼤⽂件(GB 级别的⽂件)的时候,PageCache 会不起作⽤,那就⽩⽩浪费 DRM 多做的⼀次数据拷⻉,造成性能的降低,即使使⽤了 PageCache 的零拷⻉也会损失性能
这是因为如果你有很多 GB 级别⽂件需要传输,每当⽤户访问这些⼤⽂件的时候,内核就会把它们载⼊PageCache 中,于是 PageCache 空间很快被这些⼤⽂件占满。
另外,由于⽂件太⼤,可能某些部分的⽂件数据被再次访问的概率⽐较低,这样就会带来 2 个问题:
- PageCache 由于⻓时间被⼤⽂件占据,其他「热点」的⼩⽂件可能就⽆法充分使⽤到
PageCache,于是这样磁盘读写的性能就会下降了;
- PageCache 中的⼤⽂件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷⻉到
PageCache ⼀次;
所以,针对⼤⽂件的传输,不应该使⽤ PageCache,也就是说不应该使⽤零拷⻉技术,因为可能由于PageCache 被⼤⽂件占据,⽽导致「热点」⼩⽂件⽆法利⽤到 PageCache,这样在⾼并发的环境下,会带来严重的性能问题。
# 6.⼤⽂件传输⽤什么⽅式实现?
那针对⼤⽂件的传输,我们应该使⽤什么⽅式呢?
我们先来看看最初的例⼦,当调⽤ read ⽅法读取⽂件时,进程实际上会阻塞在 read ⽅法调⽤,因为要等待磁盘数据的返回,如下图:
具体过程:
- 当调⽤ read ⽅法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
- 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷⻉到 PageCache ⾥;
- 最后,内核再把 PageCache 中的数据拷⻉到⽤户缓冲区,于是 read 调⽤就正常返回了。
对于阻塞的问题,可以⽤异步 I/O 来解决,它⼯作⽅式如下图:
它把读操作分为两部分:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷⻉到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
⽽且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使⽤异步 I/O 就意味着要绕开
PageCache。
绕开 PageCache 的 I/O 叫直接 I/O,使⽤ PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O只⽀持直接 I/O。
前⾯也提到,⼤⽂件的传输不应该使⽤ PageCache,因为可能由于 PageCache 被⼤⽂件占据,⽽导致「热点」⼩⽂件⽆法利⽤到 PageCache。
于是,在⾼并发的场景下,针对⼤⽂件的传输的⽅式,应该使⽤「异步 I/O + 直接 I/O」来替代零拷⻉技术。
直接 I/O 应⽤场景常⻅的两种:
- 应⽤程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输⼤⽂件的时候,由于⼤⽂件难以命中 PageCache 缓存,⽽且会占满 PageCache 导致「热点」⽂件⽆法充分利⽤缓存,从⽽增⼤了性能开销,因此,这时应该使⽤直接 I/O。
另外,由于直接 I/O 绕过了 PageCache,就⽆法享受内核的这两点的优化:
- 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后**「合并」**成⼀个更⼤的 I/O请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
- 内核也会**「预读」**后续的 I/O 请求放在 PageCache 中,⼀样是为了减少对磁盘的操作;
于是,传输⼤⽂件的时候,使⽤「异步 I/O + 直接 I/O」了,就可以⽆阻塞地读取⽂件了。
所以,传输⽂件的时候,我们要根据⽂件的⼤⼩来使⽤不同的⽅式:
- 传输⼤⽂件的时候,使⽤「异步 I/O + 直接 I/O」;
- 传输⼩⽂件的时候,则使⽤「零拷⻉技术」;
在 nginx 中,我们可以⽤如下配置,来根据⽂件的⼤⼩来使⽤不同的⽅式:
location /video/ {
sendfile on;
aio on;
directio 1024m;
}
当⽂件⼤⼩⼤于 directio 值后,使⽤「异步 I/O + 直接 I/O」,否则使⽤「零拷⻉技术」。
# 7.总结
早期 I/O 操作,内存与磁盘的数据传输的⼯作都是由 CPU 完成的,⽽此时 CPU 不能执⾏其他任务,会特别浪费 CPU 资源。
于是,为了解决这⼀问题,DMA 技术就出现了,每个 I/O 设备都有⾃⼰的 DMA 控制器,通过这个DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪⾥来,到哪⾥去,就可以放⼼离开了。后续的实际数据传输⼯作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的⼯作。
传统 IO 的⼯作⽅式,从硬盘读取数据,然后再通过⽹卡向外发送,我们需要进⾏ 4 上下⽂切换,和 4次数据拷⻉,其中 2 次数据拷⻉发⽣在内存⾥的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发⽣在内核态和⽤户态之间,这个数据搬移⼯作是由 CPU 完成的。
为了提⾼⽂件传输的性能,于是就出现了零拷⻉技术,它通过⼀次系统调⽤( sendfile ⽅法)合并了磁盘读取与⽹络发送两个操作,降低了上下⽂切换次数。另外,拷⻉数据都是发⽣在内核中的,天然就降低了数据拷⻉的次数。
Kafka 和 Nginx 都有实现零拷⻉技术,这将⼤⼤提⾼⽂件传输的性能。
零拷⻉技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读⽐随机读性能好的原因。这些优势,进⼀步提升了零拷⻉的性能。
需要注意的是,零拷⻉技术是不允许进程对⽂件内容作进⼀步的加⼯的,⽐如压缩数据再发送。
另外,当传输⼤⽂件时,不能使⽤零拷⻉,因为可能由于 PageCache 被⼤⽂件占据,⽽导致「热点」⼩⽂件⽆法利⽤到 PageCache,并且⼤⽂件的缓存命中率不⾼,这时就需要使⽤「异步 IO + 直接 IO 」的⽅式。
在 Nginx ⾥,可以通过配置,设定⼀个⽂件⼤⼩阈值,针对⼤⽂件使⽤异步 IO 和直接 IO,⽽对⼩⽂件使⽤零拷⻉。