1、从编程角度而言,一般磁盘I/O的场景有以下四种。
(1)用户调用标准C库进行I/O操作,数据流为:应用程序buffer→C库标准IObuffer→文件系统页缓存→通过具体文件系统到磁盘。
(2)用户调用文件 I/O,数据流为:应用程序 buffer→文件系统页缓存→通过具体文件系统到磁盘。
(3)用户打开文件时使用O_DIRECT,绕过页缓存直接读写磁盘。
(4)用户使用类似dd工具,并使用direct参数,绕过系统cache与文件系统直接写磁盘。 发起I/O请求的步骤可以表述为如下的内容(以最长链路为例)。 ·
- 写操作:用户调用fwrite把数据写入C库标准IObuffer后就返回,即写操作通常是异步操作;数据写入C库标准IObuffer后,不会立即刷新到磁盘,会将多次小数据量相邻写操作先缓存起来合并,最终调用write函数一次性写入(或者将大块数据分解多次write 调用)页缓存;数据到达页缓存后也不会立即刷新到磁盘,内核有 pdflush 线程在不停地检测脏页,判断是否要写回到磁盘,如果是则发起磁盘I/O请求。 ·
- 读操作:用户调用fread到C库标准IObuffer中读取数据,如果成功则返回,否则继续;到页缓存中读取数据,如果成功则返回,否则继续;发起 I/O 请求,读取数据后缓存buffer和C库标准IObuffer并返回。可以看出,读操作是同步请求。 ·
- I/O请求处理:通用块层根据I/O请求构造一个或多个bio结构并提交给调度层;调度器将 bio 结构进行排序和合并组织成队列且确保读写操作尽可能理想:将一个或多个进程的读操作合并到一起读,将一个或多个进程的写操作合并到一起写,尽可能变随机为顺序(因为随机读写比顺序读写要慢),读必须优先满足,而写也不能等太久。
磁盘I/O的流程
针对不同的应用场景,I/O调度策略也会影响I/O的读写性能,目前Linux系统中的I/O调度策略有4种,分别为NOOP、CFQ、DEADLINE和ANTICIPATORY,默认为CFQ。
1.NOOP
NOOP算法的全写为No Operation。该算法实现了最简单的FIFO队列,所有I/O请求大致按照先来后到的顺序进行操作。之所以说“大致”,原因是NOOP在FIFO的基础上还做了相邻I/O请求的合并,并不是完全按照先进先出的规则满足I/O请求。
假设有如下的I/O请求序列:
NOOP将会按照如下顺序满足I/O请求:
100 (101),500, 10, 56, 10002.CFQ
CFQ算法的全写为Completely Fair Queuing。该算法的特点是按照I/O请求的地址进行排序,而不是按照先来后到的顺序进行响应。 假设有如下的I/O请求序列:
100, 500, 101 , 10, 56 , 1000
CFQ将会按照如下顺序满足:
100, 101, 500, 1000, 10, 56CFQ是默认的磁盘调度算法,对于通用服务器来说是最好的选择。它试图均匀地分布对/IO带宽的访问。CFQ为每个进程单独创建一个队列来管理该进程所产生的请求,也就是说,每个进程一个队列,各队列之间的调度使用时间片进行调度,以此来保证每个进程都能被很好地分配到I/O带宽。I/O调度器每次执行一个进程的4次请求。在传统的SAS盘上,磁盘寻道花去了绝大多数的I/O响应时间。CFQ的出发点是对I/O地址进行排序,以尽量少的磁盘旋转次数来满足尽可能多的I/O请求。在CFQ算法下,SAS盘的吞吐量大大提高了。相比于NOOP的缺点是,先来的I/O请求并不一定能被满足,可能会出现“饿死”的情况
3.DEADLINE
DEADLINE在CFQ的基础上,解决了I/O请求“饿死”的极端情况。除了CFQ本身具有的I/O排序队列,DEADLINE额外分别为读I/O和写I/O提供了FIFO队列。读FIFO队列的最大等待时间为500ms,写FIFO队列的最大等待时间为5s。FIFO队列内的I/O请求优先级要比CFQ队列中的高,而读FIFO队列的优先级又比写FIFO队列的优先级高。优先级可以表示如下:
4.ANTICIPATORY
CFQ和DEADLINE考虑的焦点在于满足零散I/O请求上。对于连续的I/O请求,比如顺序读,并没有做优化。为了满足随机I/O和顺序I/O混合的场景,Linux还支持ANTICIPATORY调度算法。ANTICIPATORY在DEADLINE的基础上,为每个读I/O都设置了6ms的等待时间窗口。如果在6ms内OS收到了相邻位置的读I/O请求,就可以立即满足。ANTICIPATORY算法通过增加等待时间来获得更高的性能,假设一个块设备只有一个物理查找磁头(例如一个单独的SATA硬盘),将多个随机的小写入流合并成一个大写入流(相当于将随机读写变顺序读写),通过这个原理来使用读取/写入的延时换取最大的读取/写入吞吐量。适用于大多数环境,特别是读取/写入较多的环境。
不同的磁盘调度算法(以及相应的I/O优化手段)对Kafka 这类依赖磁盘运转的应用的影响很大,建议根据不同的业务需求来测试并选择合适的磁盘调度算法。
从文件系统层面分析,Kafka 操作的都是普通文件,并没有依赖于特定的文件系统,但是依然推荐使用EXT4或XFS。尤其是对XFS而言,它通常有更好的性能,这种性能的提升主要影响的是Kafka的写入性能。
5. 零拷贝
除了消息顺序追加、页缓存等技术,Kafka还使用零拷贝(Zero-Copy)技术来进一步提升性能。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。对 Linux操作系统而言,零拷贝技术依赖于底层的 sendfile()方法实现。对应于 Java 语言,FileChannal.transferTo()方法的底层实现就是sendfile()方法。
单纯从概念上理解“零拷贝”比较抽象,这里简单地介绍一下它。考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。这个情形就意味着需要先将静态内容从磁盘中复制出来放到一个内存buf中,然后将这个buf通过套接字(Socket)传输给用户,进而用户获得静态内容。这看起来再正常不过了,但实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:
首先调用read()将静态内容(这里假设为文件A)读取到tmp_buf,然后调用write()将tmp_buf写入Socket,如图5-23所示。
在这个过程中,文件A经历了4次复制的过程:
(1)调用read()时,文件A中的内容被复制到了内核模式下的Read Buffer中。
(2)CPU控制将内核模式数据复制到用户模式下。
(3)调用write()时,将用户模式下的内容复制到内核模式下的Socket Buffer中。
(4)将内核模式下的Socket Buffer的数据复制到网卡设备中传送。
从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了 2次复制过程:第一次是从内核模式复制到用户模式;第二次是从用户模式再复制回内核模式,即上面4次过程中的第2步和第3步。而且在上面的过程中,内核和用户模式的上下文的切换也是4次。
如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给 Socket,如图5-24所示。
零拷贝技术通过DMA(Direct Memory Access)技术将文件内容复制到内核模式下的Read Buffer 中。不过没有数据被复制到 Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到Socket Buffer中。DMA引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了2次复制就从磁盘中传送出去了,并且上下文切换也变成了2次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。