阅读 2098

客户端开发基础知识——写文件避“坑”指南

一、背景和问题

在客户端开发过程中,写文件几乎是最常见的操作之一了。操作系统、标准库、以及各级应用框架都提供了各种各样的文件写 API,如:

  • 操作系统提供的系统调用readwritefsync等;
  • c标准库提供的freadfwritefflush等方法;
  • iOS Foundation库提供的-[NSData writeToFile:atomically:]等方法;

但是,你可能会时不时地遇到一些线上出现的奇怪的问题,如:

  • Q1:写文件的 API 调用已经成功完成了,为什么读出来的数据跟写入的不一致?
  • Q2:都是成功写入,为什么有些后写入的数据写成功了,而先写入的反而丢了?
  • Q3:为什么有的用户会遇到这种不一致的情况,而另一些用户不会?

或者你会思考一些关于写文件的实现机制的问题:

  • Q4:写文件 API 调用完成后,磁盘上就已经更新成最新数据了吗?
  • Q5fflush之后呢?
  • Q6fsyncfflush是什么关系?

或者在分析 I/O 性能测试数据时发现:

  • Q7:文件读写时间的实测数据与预期不一致,波动较大?

最后,你可能希望知道:

  • Q8:怎样能保证数据被完整、可靠地写入到磁盘?

本文会对文件读写相关的几个 API 和其内部机制进行讲解,并一一回答上面的这些问题(相应的部分会标上问题编号)。

二、文件读写

1. 文件读写系统调用

要了解文件写的过程,我们先来看一下文件读写的基本接口。

按调用的层次看,所有的文件读写 API 最后都会调到操作系统提供的以下系统调用:

  • read()write()

    read()系统调用先检查文件数据是否已经加载到了内核缓存中,若否则先将其加载到内核缓存中,然后再拷贝到应用程序的用户地址空间。

write()则相反,先将应用程序地址空间中的数据拷到内核缓存中,再写入磁盘。

  • mmap()

    mmap()将内核页缓存(page cache)数据直接映射到应用程序的地址空间,使应用程序可以直接访问。当应用程序访问地址空间中的文件映射区域且相应的文件数据还未加载进来时,其触发的缺页中断会让系统会将对应的文件数据懒加载到内核页缓存中。

注:使用UBCUnified Buffer Cache,统一缓冲缓存)的系统,read/write所用的内核缓存与mmap所用的内核页缓存都是系统内统一管理的内核页缓存(图中的page cache[1]

需要注意的是:当调用write或直接操作mmap内存进行写文件时,方法调用结束并返回成功并不意味着数据已经真正写入磁盘中了,此时数据可能仍然存在内核缓存中。(Q4

2. 强制将文件写入磁盘的系统调用

强制数据从内核缓存写入磁盘,有以下两个系统调用可以使用:

  • fsync()

    fsync的功能是将其参数文件描述符(fildes)中所有修改的数据和属性写到硬盘中,但需要注意[4]

    • fsync会将所有文件数据从本机发给硬盘驱动程序,但硬盘驱动程序(为了提高效率)可能会等待一段时间才真正将这些数据写入物理磁盘,并且可能会乱序写入。(Q1、Q2

    • 如果这时硬盘掉电或者操作系统发生了崩溃,这些文件数据有可能全部未被写入磁盘,也有可能只有一部分被写入磁盘,并且可能会出现先调了fsync的数据未被写入而后调fsync的数据被写入了的情况。(Q2

    • 这种情况并非是边缘 case,设备掉电的情况下很容易出现。(Q3

    所以,fsync虽然效率较高、耗时较短,但其并不能保证数据可靠地、完整地被写入磁盘。

    注:iOS Foundation库中提供的写文件方法大都是调用了fsync,所以并不能保证掉电情况下数据写入的可靠性。

  • fcntl(F_FULLSYNC)

    如果希望保证数据写入的完整性、可靠性,可以使用fcntl方法并传入 F_FULLFSYNC,此方法能强制让硬盘驱动将所有数据写入磁盘。不过相应的,此方法耗时也较长。(Q8

结论:具体选择使用fsync还是fcntl(F_FULLSYNC),其实是一个效率可靠性之间的权衡,应用程序需要根据实际场景选择合适的方式。

3. c标准库提供的文件读写 API

c标准库提供了几个与文件相关的几个 API:freadfwritefflush

  • 这些 API 相对于readwritefsync等系统调用来说偏上层,fread/fwrite内部会调用read/write来实现其功能。

  • fread/fwrite调用完成并不保证数据已经发给操作系统内核,数据可能还存在 FILE*对应的内部缓冲区内。(Q4

  • fflush看起来与fsync系统调用相对应,但实际上fflush只是将FILE*内部的数据同步到系统内核缓存中,其内部并不会调用fsync[3]。(Q5、Q6

  • 因此对于FILE*类型的数据来说,要保证数据完整地被写到磁盘,应该先调用fflush,再调用fsyncfcntl(F_FULLSYNC)。(Q8

4. 可靠地测试文件读、写性能

  • 在测试应用程序文件I/O性能时最好禁用UBC,否则当系统的内核页缓存里已经有数据时,上层的应用会直接使用缓存数据,导致首次与非首次之间I/O时间差别较大(Q7):

    //禁用UBC(需要保证文件之前不在cache中,否则还是会使用cache)
    fcntl(fd, F_NOCACHE, 1)
    复制代码

注:F_GLOBAL_NOCACHE是直接将所有对此文件的访问都置为不使用UBC,但目前行为是跟F_NOCACHE是一样的[2]

三、参考资料

  1. UBC: An Efficient Unified I/O and Memory Caching Subsystem for NetBSD
  2. developer.apple.com/forums/thre…
  3. stackoverflow.com/questions/2…
  4. fsync(2) man page (Mac OS X)

四、欢迎关注我的微信公众号,有空多多交流