Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.5 编程中的文件操作

文件操作是编程中最常见的操作之一,我们几乎每天都在和文件打交道。但文件操作有很多容易踩的坑,理解底层原理,遵循最佳实践,能帮助我们写出更高效、更安全的文件操作代码。

文件操作的基本接口

文件操作的接口通常分为两层:系统调用层和标准库层。

系统调用

操作系统提供的底层文件操作接口,直接和内核交互,不同操作系统的系统调用略有不同:

  • Unix/Linux:open(), read(), write(), close(), lseek(), fsync(), mmap()
  • Windows:CreateFile(), ReadFile(), WriteFile(), CloseHandle()

系统调用是最底层的接口,功能强大,但使用起来比较繁琐,而且不跨平台。

标准库接口

编程语言的标准库对系统调用进行了封装,提供了跨平台的、更易用的接口:

  • C语言:fopen(), fread(), fwrite(), fclose(), fseek()等(stdio库)
  • Python:open()函数,文件对象的read(), write()等方法
  • Java:FileInputStream, FileOutputStream, BufferedReader等类
  • Go:os包下的Open(), Read(), Write()等函数

标准库接口通常带缓冲,比直接调用系统调用性能更好,而且跨平台,是我们日常开发的首选。

高级文件操作特性

1. 内存映射文件(mmap)

内存映射文件是一种高级的文件操作方式,它把文件的内容映射到进程的虚拟地址空间,用户可以像访问内存一样访问文件,不需要调用read/write系统调用。

工作原理

  • 把文件的页直接映射到进程的地址空间
  • 访问映射的内存时,操作系统会自动把对应的文件页加载到内存
  • 修改映射的内存时,操作系统会在合适的时机把修改写回磁盘

优点

  • 访问大文件速度快,不需要频繁的系统调用和数据拷贝
  • 可以随机访问文件,像操作数组一样操作文件
  • 可以实现进程间共享内存
  • 比read/write性能更好,减少了内核态和用户态之间的数据拷贝

缺点

  • 不适合非常小的文件,页对齐会浪费空间
  • 映射长度受限于虚拟地址空间大小
  • 出现IO错误时会收到SIGBUS信号,需要特殊处理

适用场景:大文件处理、随机访问、进程间共享数据。

示例(Python)

import mmap

with open('large_file.bin', 'r+b') as f:
    # 映射整个文件
    mm = mmap.mmap(f.fileno(), length=0, access=mmap.ACCESS_WRITE)
    # 像访问字节数组一样访问文件
    print(mm[0:10])  # 读取前10字节
    mm[0:5] = b'hello'  # 修改前5字节
    mm.flush()  # 手动刷回磁盘
    mm.close()

2. 稀疏文件

稀疏文件是一种特殊的文件,文件中大部分内容都是0,文件系统不会为全0的块分配实际的磁盘空间,节省磁盘空间。

特点

  • 显示的文件大小是逻辑大小,实际占用的磁盘空间很小
  • 只有当写入非0数据时才会分配实际的磁盘块
  • 很多下载工具、虚拟机镜像、数据库文件都使用稀疏文件节省空间

示例: 创建一个1GB的稀疏文件,实际只占用几KB磁盘空间:

$ dd if=/dev/zero of=sparse_file bs=1G count=0 seek=1
$ ls -lh sparse_file  # 显示大小1G
-rw-r--r-- 1 user user 1.0G Mar 12 11:00 sparse_file
$ du -h sparse_file  # 实际占用4.0K
4.0K    sparse_file

3. 直接IO(Direct IO)

直接IO绕过操作系统的Page Cache,直接和磁盘交互,数据不经过内核缓冲区。

适用场景

  • 数据库等有自己缓存机制的应用,不需要内核缓存,避免双重缓存浪费内存
  • 需要自己控制IO调度的场景

缺点

  • 性能通常比带缓存的IO差很多,因为没有利用Page Cache
  • 内存缓冲区需要页对齐,使用起来比较麻烦

4. 异步IO

异步IO发起IO请求后立即返回,不需要等待IO完成,IO完成后操作系统会通知应用程序,提高并发能力。不同编程语言和平台有不同的异步IO实现,比如Linux的io_uring,Windows的IOCP,Python的asyncio等。

常见文件操作坑点与最佳实践

坑点1:忘记关闭文件

  • 问题:打开文件后忘记关闭,会导致文件描述符泄漏,系统的文件描述符是有限的,泄漏完了就无法再打开新的文件
  • 解决方法
    • 使用RAII机制,比如Python的with语句,Java的try-with-resources,C++的智能指针,自动关闭文件
    • 一定要在finally块中关闭文件
    • 不要长时间打开不需要的文件,用完就关

正确示例(Python)

# 好的写法,with语句会自动关闭文件
with open('file.txt', 'r') as f:
    content = f.read()

# 不好的写法,可能忘记关闭
f = open('file.txt', 'r')
content = f.read()
# 如果中间抛出异常,f.close()不会执行
f.close()

坑点2:忽略文件操作异常

  • 问题:文件操作很容易出现异常(磁盘满、权限不足、文件不存在、磁盘损坏等),忽略异常会导致程序出现难以排查的问题,甚至数据丢失
  • 解决方法
    • 所有文件操作都要捕获并处理异常
    • 发生异常时要保证资源正确释放,数据一致性不受破坏
    • 重要的文件操作要进行错误校验,不要假设操作一定会成功

坑点3:错误处理编码问题

  • 问题:文本文件的编码处理错误,导致乱码或者读取失败
  • 解决方法
    • 打开文本文件时明确指定编码,不要依赖系统默认编码
    • 二进制文件用二进制模式打开(rb/wb),不要用文本模式
    • 尽可能统一使用UTF-8编码,减少编码问题

正确示例(Python)

# 明确指定编码
with open('file.txt', 'r', encoding='utf-8') as f:
    content = f.read()

# 二进制文件用二进制模式
with open('image.jpg', 'rb') as f:
    data = f.read()

坑点4:刷盘策略错误

  • 问题
    • 重要数据不调用fsync,系统崩溃或断电时数据丢失
    • 频繁调用fsync,严重影响性能
  • 解决方法
    • 需要持久化的数据,修改完成后调用fsync确保写到磁盘
    • 不需要立即持久化的数据,不要频繁调用fsync,让操作系统自动刷盘
    • 可以批量操作完成后统一调用一次fsync,平衡性能和安全性

坑点5:路径处理错误

  • 问题
    • 硬编码路径,跨平台不兼容(Windows用\,Linux用/)
    • 路径拼接错误,导致找不到文件
    • 没有处理用户输入的路径,导致路径穿越漏洞
  • 解决方法
    • 不要硬编码路径,使用配置文件或者环境变量配置
    • 使用编程语言提供的路径处理库拼接路径,比如Python的os.path.join()或者pathlib,Java的Paths,不要自己拼接字符串
    • 对用户传入的路径进行严格校验,规范化后再使用,防止路径穿越

正确示例(Python)

from pathlib import Path

# 跨平台路径处理
base_dir = Path('/data')
file_path = base_dir / 'user' / 'file.txt'  # 自动处理路径分隔符

# 解析为绝对路径,处理../等
resolved_path = file_path.resolve()

坑点6:大文件读取问题

  • 问题:读取大文件时一次性读入内存,导致内存不足OOM
  • 解决方法
    • 大文件分块读取,逐行或者逐块处理,不要一次性读入内存
    • 使用生成器或者迭代器处理大文件
    • 大文件处理优先使用内存映射文件

正确示例

# 大文件逐行处理,不会占用太多内存
with open('large_file.txt', 'r', encoding='utf-8') as f:
    for line in f:
        process(line)

文件操作性能优化

1. 批量操作

合并小的读写操作,减少系统调用次数,比如把多次小的write合并成一次大的write,减少用户态和内核态的切换开销。

2. 充分利用缓存

  • 操作系统的Page Cache会自动缓存热点数据,不要随便使用直接IO绕过缓存
  • 应用层可以对热点文件内容进行缓存,减少磁盘IO次数
  • 顺序读写比随机读写性能好很多,特别是对于HDD,尽量将随机IO转换为顺序IO

3. 选择合适的缓冲区大小

  • 缓冲区太小会导致频繁的系统调用,性能差
  • 缓冲区太大会浪费内存,而且如果进程崩溃会丢失更多数据
  • 通常缓冲区大小设置为4KB、8KB、32KB比较合适,和磁盘块大小对齐性能最好

4. 预读优化

如果要顺序读取大文件,可以开启文件的预读选项,操作系统会提前把后续的内容加载到缓存中,提升读取性能。

5. 合理设置刷盘频率

  • 不需要强持久化的场景,不要频繁刷盘,让操作系统自动批量刷盘,提升性能
  • 需要强持久化的场景,批量操作后再刷盘,不要每次操作都刷盘

思考问题

  1. 内存映射文件相比传统的read/write方式有什么优势?适合什么场景?
  2. 为什么打开文件后一定要关闭?忘记关闭会有什么后果?
  3. 读取一个10GB的日志文件,一次性读入内存会有什么问题?应该怎么处理?
  4. 什么场景下需要调用fsync?频繁调用fsync会有什么问题?