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块中关闭文件
- 不要长时间打开不需要的文件,用完就关
- 使用RAII机制,比如Python的
正确示例(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. 合理设置刷盘频率
- 不需要强持久化的场景,不要频繁刷盘,让操作系统自动批量刷盘,提升性能
- 需要强持久化的场景,批量操作后再刷盘,不要每次操作都刷盘
思考问题
- 内存映射文件相比传统的read/write方式有什么优势?适合什么场景?
- 为什么打开文件后一定要关闭?忘记关闭会有什么后果?
- 读取一个10GB的日志文件,一次性读入内存会有什么问题?应该怎么处理?
- 什么场景下需要调用fsync?频繁调用fsync会有什么问题?