一直对Linux的磁盘缓存机制有所耳闻,刚毕业那会领导教我reboot之前要最好先执行sync命令,保证修改的文件同步到磁盘,避免文件数据丢失。后来工作中确实碰到过几次文件数据丢失引发的bug,也都是通过fsync函数缓存数据强制同步到磁盘中解决的。这样只能说是“知其然而不知其所以然”,本着追根究底的精神,学习了一波Linux内核磁盘缓存机制。
内核磁盘缓存策略
Linux内核使用页高速缓存实现磁盘缓存,通过把磁盘中的数据缓存到物理内存中,将对磁盘的访问更改为对物理内存的访问,从而提升读写性能。
缓存策略
读缓存
当内核开始一个读操作,如果数据在页高速缓存中,则称为缓存命中,此时直接从内存中读取;如果数据不在缓存中,则称为缓存未命中,此时内核必须调度块I/O操作从磁盘中读取数据。
写缓存
Linux采用回写的策略实现写缓存,即程序执行写操作直接写到缓存中,此时不立即将数据写入磁盘,而是把页高速缓存中被写入的页标记成“脏”,并且加入到脏页链表中,然后由回写进程周期性的将脏页写回到磁盘,最后清理脏页标识。
缓存回收
Linux的缓存回收是通过选择干净页(不脏)进行简单替换,如果缓存中没有足够的干净页,内核强制进行回写操作,以腾出更多干净页。
通常使用LRU算法作为回收策略,不过当许多文件被访问一次后,再不被访问的情景时,LRU的效果并不理想。因此采用双链策略进行优化:
- 维护两个链表:活跃链表和非活跃链表
- 在活跃链表中的页面必须在其被访问时就处于非活跃链表中
- 如果活跃链表变得过多而超过了非活跃链表,就将活跃链表的头页面移回到非活跃链表中
实现
由于写缓存时没有立即写入磁盘,因此需要另外的线程进行回写,内核版本2.6之前由bdflush和kupdate线程完成,2.6开始由pdflush完成,2.6.32之后由flusher线程完成。
flusher线程
由于页高速缓存的缓存作用,写操作会被延迟,flusher线程用于控制回写机制,其回写策略如下:
- 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘以便释放内存
- 当脏页在内存中驻留时间超过一个特定的阈值时
- 当用户调用
sync和fsync系统调用时
具体工作流程如下:
-
当空闲内存比
dirty_background_ratio更低时,内核调用函数flusher_threads()唤醒一个或多个flusher线程 -
flusher线程进一步调用函数
bdi_writeback_all()将脏页写回磁盘,直到满足如下条件- 已经有指定最小数目的页被写入到磁盘
- 空闲内存已经回升,超过阈值
dirty_backgroud_ratio
-
系统启动时,内核初始化一个定时器,让它周期性唤醒flusher线程
-
flusher线程运行函数
wb_writeback(),将所有驻留时间超过dirty_expire_interval_ms的脏页写回
下图是Linux支持的页回写设置:
bdflush线程和kupdate线程
当系统可用内存过低时,bdflush线程在后台执行脏页回写操作。它与flusher的区别在于:
- 系统中只有一个
bdflush线程,而flusher线程的数目却是根据磁盘数量变化的 bdflush线程基于缓冲,它将脏缓冲写回磁盘,flusher线程基于页面,它将整个脏页写回磁盘
kupdate线程负责周期回写脏页。
pdflush线程
内核2.6开始将bdflush和kupdate的工作合并到pdflush线程中。由于bdflush仅包含一个线程,页回写任务很重时容易造成拥塞,因此pdflush的线程数目是动态的,默认2到8个,具体取决于系统的I/O的负载。线程数动态变化,避免由于一个磁盘拥塞导致其他磁盘饥饿的问题,但可能存在多个pdflush线程挂起在相同的拥塞队列上,为了避免这个问题,采用拥塞回避策略,主动尝试从没有拥塞的队列中回写页。
而flusher的线程模型和具体块设备关联,每个磁盘对应一个线程。每个给定线程从给定设备的脏页链表收集数据,回写到对应的磁盘,无需复杂的拥塞回避策略,从而避免复杂的拥塞回避机制。
数据丢失
了解磁盘缓存策略后,我们知道每次调用write写入数据时仅将数据写入页高速缓冲中,只有满足flusher线程的回写条件时才会把脏页写入磁盘。这样就会出现一个问题,如果系统调用write完成之后设备如果意外断电,这时就会发生数据丢失的问题,Linux提供了几种方式控制内核缓冲。
fsync
#include <unistd.h>
int fsync(int fd);
fsync()系统调用将使缓冲数据和与打开文件描述符 fd 相关的所有元数据都刷新到磁盘 上。
sync
#include <unistd.h>
int sync(int fd);
sync()系统调用会使包含更新文件信息的所有内核缓冲区(即数据块、指针块、元数据等) 刷新到磁盘上
O_SYNC
调用 open()函数时如指定 O_SYNC 标志,则会使所有后续输出同步(synchronous)
fd = open(pathname, O_WRONLY | O_SYNC );
采用 O_SYNC 标志(或者频繁调用 fsync()、fdatasync()或 sync())对性能的影响极大,这是因为系统在将每个缓冲区中数据向磁盘传递时会把程序阻塞起来。
对如下程序做对比测试:
size_t numBytes = 1000000;
size_t bufSize = 1024;
char *buf = malloc(bufSize);
if (buf == NULL)
errExit("malloc");
int openFlags = O_CREAT | O_WRONLY;
int fd = open(argv[1], openFlags, S_IRUSR | S_IWUSR);
if (fd == -1)
errExit("open");
size_t thisWrite, totWritten;
for (totWritten = 0; totWritten < numBytes;
totWritten += thisWrite) {
thisWrite = min(bufSize, numBytes - totWritten);
if (write(fd, buf, thisWrite) != thisWrite)
fatal("partial/failed write");
}
不设置O_SYNC 标志时,使用time统计的耗时情形如下:
| real | user | sys |
|---|---|---|
| 0.008s | 0.006s | 0.003s |
设置O_SYNC 标志时的统计时间如下:
| real | user | sys |
|---|---|---|
| 0.973s | 0.007s | 0.046s |
可以看出执行总耗时从0.008s上升到0.973s,运行效率相差一百多倍,因此不应该在打开文件时使用O_SYNC标志,如果确实需要强制刷新内核缓冲区,应该在设计应用时考虑是否可以使用大尺寸的write缓冲区,或者谨慎的调用fsync()或sync()。
最后,当我们操作比较重要的文件数据时,要提前考虑异常情况下文件丢失问题。