3. 文件I/O进阶:lseek系统调用、文件共享、原子操作与竞争条件
文件I/O,说白了就是读写数据。但你真的以为只是读和写这么简单?
我刚开始做系统编程时,也这么想。直到有一次,我在一个高并发服务器上调试一个诡异的bug——日志文件偶尔会丢失几行数据。查了三天,最后发现是文件偏移量的问题。嗯,从那以后,我再也不敢小看文件I/O的细节了。
3.1 lseek:文件偏移量的手动挡
每个打开的文件,内核都维护着一个当前文件偏移量(current file offset)。它决定了下一次读或写操作从哪里开始。默认情况下,打开文件时偏移量是0,每次读写后自动前进。
但有时候,你需要手动控制这个偏移量。比如,你想跳到一个文件的第100个字节去读数据,或者想追加写入。这时候就需要lseek了。
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
whence参数决定了偏移量的计算方式:
| whence | 含义 |
|---|---|
| SEEK_SET | 从文件开头算起,偏移量为offset |
| SEEK_CUR | 从当前位置算起,偏移量为offset(可正可负) |
| SEEK_END | 从文件末尾算起,偏移量为offset(通常为负值) |
举个例子:
int fd = open("data.bin", O_RDONLY);
lseek(fd, 1024, SEEK_SET); // 跳到第1024字节
char buf[64];
read(fd, buf, 64); // 从第1024字节开始读64字节
lseek(fd, 0, SEEK_CUR) 可以获取当前偏移量,而不改变它。这在调试时特别有用。
3.2 文件共享:多个文件描述符指向同一个文件
这是很多新手容易搞混的地方。多个进程(或同一个进程中的多个线程)可以打开同一个文件。但内核是怎么管理它们的?
内核维护着三层数据结构:
- 进程级文件描述符表:每个进程一张,每个条目指向一个文件表项
- 系统级文件表:全局共享,每个表项包含文件偏移量、状态标志等
- v-node(虚拟节点):每个打开的文件一个,包含文件元数据(大小、权限等)
关键点: 同一个文件被打开两次,会创建两个独立的文件表项。它们各自维护自己的偏移量。但如果是通过 dup 或 fork 复制的文件描述符,则共享同一个文件表项。
我举个例子你就明白了:
int fd1 = open("test.txt", O_RDWR);
int fd2 = open("test.txt", O_RDWR);
// fd1 和 fd2 各有各的偏移量
int fd3 = dup(fd1);
// fd1 和 fd3 共享同一个偏移量
为什么会这样?你想想看,如果两个进程同时写同一个文件,各自维护独立的偏移量,那数据不就乱套了?嗯,这就是为什么需要共享文件表项——让多个文件描述符能协同工作。
3.3 原子操作:要么全做,要么不做
原子操作,说白了就是不可分割的操作。要么全部执行成功,要么就像什么都没发生过一样。
在文件I/O中,最经典的原子操作需求是文件追加。假设两个进程同时往一个日志文件里写数据:
// 进程A
lseek(fd, 0, SEEK_END);
write(fd, "AAAA", 4);
// 进程B
lseek(fd, 0, SEEK_END);
write(fd, "BBBB", 4);
如果进程A执行完lseek后,还没来得及write,进程B也执行了lseek。这时候两个进程的偏移量指向同一个位置。结果就是:一个进程的数据覆盖了另一个的。日志文件里可能只有4个字节,而不是8个。
解决方案?用 O_APPEND 标志打开文件:
int fd = open("log.txt", O_WRONLY | O_APPEND);
这样每次write前,内核会自动把偏移量移到文件末尾。而且这个操作是原子的——lseek和write之间不会被中断。
3.4 竞争条件:多进程的噩梦
竞争条件(race condition)是指多个进程或线程同时访问共享资源,而最终结果取决于它们执行的相对顺序。这玩意儿是系统编程中最难调试的bug之一。
来看一个经典例子:两个进程都想往同一个文件里写入数据,而且要求数据不能交错。
// 错误的做法
if (lseek(fd, 0, SEEK_END) == -1) {
// 错误处理
}
write(fd, data, len);
两个进程同时执行这段代码,就会产生竞争条件。一个进程的lseek和write之间,另一个进程插了进来。
正确的做法是使用文件锁:
struct flock lock;
lock.l_type = F_WRLCK; // 写锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0; // 锁定整个文件
fcntl(fd, F_SETLKW, &lock); // 阻塞等待锁
write(fd, data, len);
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock); // 解锁
3.5 知识体系总览
下面这张图展示了本章的核心逻辑关系:
3.6 实战经验总结
说了这么多,我总结几条实战中最重要的原则:
- 能用O_APPEND就别手动lseek+write——原子操作是免费的午餐
- 多进程写日志文件一定要加锁——别问我怎么知道的
- lseek返回-1时记得检查errno——管道和套接字不支持定位
- 空洞文件不占磁盘空间——但ls看到的大小是虚的
文件I/O看似简单,但真正用好它,需要理解内核的底层机制。我个人习惯在写任何涉及文件共享的代码前,先画一张数据结构关系图。这样能避免很多隐蔽的bug。
核心思想: 文件I/O的难点不在于API本身,而在于多进程/多线程环境下的并发控制。记住一句话:共享状态是万恶之源。尽量减少共享,必须共享时用原子操作或锁来保护。
嗯,这一章的内容就到这里。文件I/O的进阶知识,说白了就是理解内核怎么管理文件描述符和偏移量,以及怎么在并发环境下安全地操作它们。下次你遇到文件读写的问题,不妨先想想:是不是竞争条件在作怪?