本文转载自https://0xffffff.org/2017/05/01/41-linux-io/
作者: 浅墨
编者语
最近在看 Redis 持久化的相关原理,antirez 在他的 《Redis 持久化解密》(连结见文末)一文中说过,数据库中带有持久化的写操作分为如下几个步骤:1.客户端传送写操作命令和资料;(资料在客户端内存)2.服务端通过网络收到客户端发来的写操作和资料;(资料在服务端内存)3.服务端修改内存中的资料,同时呼叫系统函式write进行操作,将资料往磁盘中写;(资料在服务端的系统内存缓冲区)4.操作系统将缓冲区中的资料转移到磁盘上(资料在磁盘快取中)5.磁盘将资料写到磁盘的物理介质中(资料真正落到磁盘上)
对于持久化,我们需要分析下面两种情况时能否保证资料不丢失:
数据库异常崩溃整个系统断电
对于 Redis 这种应用程序(数据库服务)来说,只要步骤3操作成功,即使数据库崩溃,资料也会有核心保证写入到磁盘中,不会发生丢失;但是如果整个系统断电这种场景来说,必须要等到步骤5操作成功,才可认为写操作是真正的持久化成功。
而从步骤3到步骤5中间会涉及到大量 Linux IO 的原理,特别是Page Cache 和 Buffer Cache 等快取。我在网上搜索相关学习资料很久,发现本篇文章十分详尽,争取原作者同意,并且其部落格最下面有协议,非商业使用可以随意署名转载,所以就转载过来,供大家一起学习。
文章比较长,所以我进行一部分删减,列出剩下内容中比较重要的知识点,大家可以选取自己感兴趣的部分进行阅读,节约大家的时间。
Linux IO 快取体系,stdio和核心快取的区别,Page Cache和Buffer Cache的区别。Buffered IO、mmap(2)、Direct IO的区别。Write Through和Write back两种快取更新策略。
写在前边
在开始正式的讨论前,我先丢掷几个问题:谈到磁盘时,常说的 HDD 磁盘和 SSD 磁盘最大的区别是什么?这些差异会影响我们的系统设计吗?单执行绪写档案有点慢,那多开几个执行绪一起写是不是可以加速呢?write(2)函式成功返回了,资料就已经成功写入磁盘了吗?此时装置断电会有影响吗?会丢失资料吗?write(2)呼叫是原子的吗?多执行绪写档案是否要对档案加锁?有没有例外,比如O_APPEND方式?坊间传闻,mmap(2)的方式读档案比传统的方式要快,因为少一次拷贝。真是这样吗?为什么少一次拷贝?
如果你觉得这些问题都很简单,都能很明确的回答上来。那么很遗憾这篇文章不是为你准备的,你可以关掉网页去做其他更有意义的事情了。如果你觉得无法明确的回答这些问题,那么就耐心地读完这篇文章,相信不会浪费你的时间。
受限于个人时间和文章篇幅,部分议题如果我不能给出更好的解释或者已有专业和严谨的资料,就只会给出相关的参考文献的连结,请读者自行参阅。
言归正传,我们的讨论从储存器的层次结构开始。
储存器的金字塔结构
受限于储存介质的存取速率和成本,现代计算机的储存结构呈现为金字塔型[1]。越往塔顶,存取效率越高、但成本也越高,所以容量也就越小。得益于程式访问的区域性性原理[2],这种节省成本的做法也能取得不俗的执行效率。从储存器的层次结构以及计算机对资料的处理方式来看,上层一般作为下层的 Cache 层来使用(广义上的 Cache )。比如暂存器快取 CPU Cache 的资料,CPU Cache L1~L3层视具体实现彼此快取或直接快取内存的资料,而内存往往快取来自本地磁盘的资料。
本文主要讨论磁盘IO操作,故只聚焦于 Local Disk 的访问特性和其与 DRAM 之间的资料互动。
无处不在的快取
如图,当程式呼叫各类档案操作函式后,使用者资料(User Data)到达磁盘(Disk)的流程如图所示[3]。图中描述了 Linux 下档案操作函式的层级关系和内存快取层的存在位置。中间的黑色实线是使用者态和核心态的分界线。
从上往下分析这张图,首先是C语言 stdio 库定义的相关档案操作函式,这些都是使用者态实现的跨平台封装函式。stdio中实现的档案操作函式有自己的stdio buffer,这是在使用者态实现的快取。此处使用快取的原因很简单——系统呼叫总是昂贵的。如果使用者程式码以较小的 size 不断的读或写档案的话,stdio 库将多次的读或者写操作通过buffer进行聚合是可以提高程式执行效率的。stdio库同时也支援fflush(3)函式来主动的重新整理 buffer,主动的呼叫底层的系统呼叫立即更新 buffer 里的资料。特别地,setbuf(3)函式可以对 stdio 库的使用者态 buffer 进行设定,甚至取消 buffer 的使用。
系统呼叫的read(2)/write(2)和真实的磁盘读写之间也存在一层 buffer,这里用术语Kernel buffer cache来指代这一层快取。在Linux下,档案的快取习惯性的称之为Page Cache,而更低一级的装置的快取称之为Buffer Cache. 这两个概念很容易混淆,这里简单的介绍下概念上的区别:Page Cache用于快取档案的内容,和档案系统比较相关。档案的内容需要对映到实际的物理磁盘,这种对映关系由档案系统来完成;Buffer Cache用于快取储存装置块(比如磁盘扇区)的资料,而不关心是否有档案系统的存在(档案系统的元资料快取在Buffer Cache中)。
综上,既然讨论 Linux 下的 IO 操作,自然是跳过 stdio 库的使用者态这一堆东西,直接讨论系统呼叫层面的概念了。对 stdio 库的 IO 层有兴趣的同学可以自行去了解。从上文的描述中也介绍了档案的核心级快取是储存在档案系统的Page Cache中的。所以后面的讨论基本上是讨论 IO 相关的系统呼叫和档案系统Page Cache的一些机制。
Linux核心中的IO栈
这一小节来看 Linux 核心的 IO 栈的结构。先上一张全貌图[4]:由图可见,从系统呼叫的界面再往下,Linux下的 IO 栈致大致有三个层次:
档案系统层,以 write(2) 为例,核心拷贝了write(2)引数指定的使用者态资料到档案系统 Cache 中,并适时向下层同步块层,管理块装置的 IO 伫列,对 IO 请求进行合并、排序(还记得操作系统课程学习过的IO排程算法吗?)装置层,通过 DMA 与内存直接互动,完成资料和具体装置之间的互动
结合这个图,想想Linux系统程式设计里用到的Buffered IO、mmap(2)、Direct IO,这些机制怎么和 Linux IO 栈联络起来呢?上面的图有点复杂,我画一幅简图,把这些机制所在的位置新增进去:
这下一目了然了吧?传统的 Buffered IO 使用read(2)读取档案的过程什么样的?假设要去读一个冷档案(Cache中不存在),open(2)开启档案核心后建立了一系列的资料结构,接下来呼叫read(2),到达档案系统这一层,发现Page Cache中不存在该位置的磁盘对映,然后建立相应的Page Cache并和相关的扇区关联。然后请求继续到达块装置层,在 IO 伫列里排队,接受一系列的排程后到达装置驱动层,此时一般使用DMA方式读取相应的磁盘扇区到 Cache 中,然后read(2)拷贝资料到使用者提供的使用者态buffer 中去(read(2)的引数指出的)
整个过程有几次拷贝?从磁盘到Page Cache算第一次的话,从Page Cache到使用者态buffer就是第二次了。而mmap(2)做了什么?mmap(2)直接把Page Cache对映到了使用者态的地址空间里了,所以mmap(2)的方式读档案是没有第二次拷贝过程的。那Direct IO做了什么?这个机制更狠,直接让使用者态和块 IO 层对接,直接放弃Page Cache,从磁盘直接和使用者态拷贝资料。好处是什么?写操作直接对映程序的buffer到磁盘扇区,以 DMA 的方式传输资料,减少了原本需要到Page Cache层的一次拷贝,提升了写的效率。对于读而言,第一次肯定也是快于传统的方式的,但是之后的读就不如传统方式了(当然也可以在使用者态自己做 Cache,有些商用数据库就是这么做的)。
除了传统的 Buffered IO 可以比较自由的用偏移+长度的方式读写档案之外,mmap(2)和 Direct IO 均有资料按页对齐的要求,Direct IO 还限制读写必须是底层储存装置块大小的整数倍(甚至 Linux 2.4 还要求是档案系统逻辑块的整数倍)。所以界面越来越底层,换来表面上的效率提升的背后,需要在应用程序这一层做更多的事情。所以想用好这些高阶特性,除了深刻理解其背后的机制之外,也要在系统设计上下一番功夫。
Page Cache 的同步
广义上 Cache 的同步方式有两种,即Write Through(写穿)和Write back(写回). 从名字上就能看出这两种方式都是从写操作的不同处理方式引出的概念(纯读的话就不存在 Cache 一致性了,不是么)。对应到 Linux 的Page Cache上所谓Write Through就是指write(2)操作将资料拷贝到Page Cache后立即和下层进行同步的写操作,完成下层的更新后才返回。而Write back正好相反,指的是写完Page Cache就可以返回了。Page Cache到下层的更新操作是异步进行的。Linux下Buffered IO预设使用的是Write back机制,即档案操作的写只写到Page Cache就返回,之后Page Cache到磁盘的更新操作是异步进行的。Page Cache中被修改的内存页称之为脏页(Dirty Page),脏页在特定的时候被一个叫做 pdflush (Page Dirty Flush)的核心执行绪写入磁盘,写入的时机和条件如下:
当空闲内存低于一个特定的阈值时,核心必须将脏页写回磁盘,以便释放内存。当脏页在内存中驻留时间超过一个特定的阈值时,核心必须将超时的脏页写回磁盘吧使用者程序呼叫sync(2)、fsync(2)、fdatasync(2)系统呼叫时,核心会执行相应的写回操作。
重新整理策略由以下几个引数决定(数值单位均为1/100秒):
# flush每隔5秒执行一次
[email protected] / $ sysctl vm.dirty_writeback_centisecs
vm.dirty_writeback_centisecs = 500
# 内存中驻留30秒以上的脏资料将由flush在下一次执行时写入磁盘
[email protected] / $ sysctl vm.dirty_expire_centisecs
vm.dirty_expire_centisecs = 3000
# 若脏页占总实体内存10%以上,则触发flush把脏资料写回磁盘
[email protected] / $ sysctl vm.dirty_background_ratio
vm.dirty_background_ratio = 10
预设是写回方式,如果想指定某个档案是写穿方式呢?即写操作的可靠性压倒效率的时候,能否做到呢?当然能,除了之前提到的fsync(2)之类的系统呼叫外,在open(2)开启档案时,传入O_SYNC这个 flag 即可实现。这里给篇参考文章[5],不再赘述(更好的选择是去读TLPI相关章节)。
档案读写遭遇断电时,资料还安全吗?相信你有自己的答案了。使用O_SYNC或者fsync(2)重新整理档案就能保证安全吗?现代磁盘一般都内建了快取,程式码层面上也只能讲资料重新整理到磁盘的快取了。当资料已经进入到磁盘的快取内存时断电了会怎么样?这个恐怕不能一概而论了。不过可以使用hdparm -W0命令关掉这个快取,相应的,磁盘效能必然会降低。
写在最后
每天抽出不到半个小时,零零散散地写了一周,这是说是入门都有些谬赞了,只算是对Linux下的 IO 机制稍微深入的介绍了一点。无论如何,希望学习完 Linux 系统程式设计的同学,能继续的往下走一走,尝试理解系统呼叫背后隐含的机制和原理。探索的结果无所谓,重要的是探索的过程以及相关的学习经验和方法。前文提出的几个问题我并没有刻意去解答所有的,但是读到现在,不知道你自己能回答上几个了?微信公众号连线 聊聊Linux IO
参考文献
[1] 图片引自《Computer Systems: A Programmer’s Perspective》Chapter 6 The Memory Hierarchy, 另可参考https://zh.wikipedia.org/wiki/%E5%AD%98%E5%82%A8%E5%99%A8%E5%B1%B1
[2] Locality of reference,https://en.wikipedia.org/wiki/Locality_of_reference
[3] 图片引自《The Linux Programming Interface》Chapter 13 FILE I/O BUFFERING
[4] Linux Storage Stack Diagram, https://www.thomas-krenn.com/en/wiki/Linux_Storage_Stack_Diagram
[5] O_DIRECT和O_SYNC详解, http://www.cnblogs.com/suzhou/p/5381738.html
[6] http://librelist.com/browser/usp.ruby/2013/6/5/o-append-atomicity/
[7] Coding for SSD, https://dirtysalt.github.io/coding-for-ssd.html
[8] fio作者Jens Axboe是Linux核心IO部分的maintainer,工具主页 http://freecode.com/projects/fio/
[9] How to benchmark disk, https://www.binarylane.com.au/support/solutions/articles/1000055889-how-to-benchmark-disk-i-o
[10] 深入Linux核心架构, (德)莫尔勒, 人民邮电出版社
redis 持久化解密 http://oldblog.antirez.com/post/redis-persistence-demystified.html