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) 可以获取当前偏移量,而不改变它。这在调试时特别有用。
注意: lseek 不能用于管道、套接字等非可定位文件。调用会返回 -1,errno 设置为 ESPIPE。我当年就在这上面栽过跟头。

3.2 文件共享:多个文件描述符指向同一个文件

这是很多新手容易搞混的地方。多个进程(或同一个进程中的多个线程)可以打开同一个文件。但内核是怎么管理它们的?

内核维护着三层数据结构:

  1. 进程级文件描述符表:每个进程一张,每个条目指向一个文件表项
  2. 系统级文件表:全局共享,每个表项包含文件偏移量、状态标志等
  3. 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之间不会被中断。

避坑指南: 我曾经在一个多线程程序中手动实现追加写入(先lseek再write),结果在高并发下数据总是乱掉。后来改用O_APPEND,问题立刻消失。记住:不要自己拼凑原子操作,让内核帮你做。

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 知识体系总览

下面这张图展示了本章的核心逻辑关系:

文件I/O进阶知识体系 文件I/O进阶 lseek 系统调用 SEEK_SET/SEEK_CUR/SEEK_END 空洞文件 文件共享 进程级描述符表 系统级文件表 原子操作 O_APPEND 标志 pread/pwrite 竞争条件 文件锁 (fcntl) 建议性锁 vs 强制性锁

3.6 实战经验总结

说了这么多,我总结几条实战中最重要的原则:

  • 能用O_APPEND就别手动lseek+write——原子操作是免费的午餐
  • 多进程写日志文件一定要加锁——别问我怎么知道的
  • lseek返回-1时记得检查errno——管道和套接字不支持定位
  • 空洞文件不占磁盘空间——但ls看到的大小是虚的

文件I/O看似简单,但真正用好它,需要理解内核的底层机制。我个人习惯在写任何涉及文件共享的代码前,先画一张数据结构关系图。这样能避免很多隐蔽的bug。

核心思想: 文件I/O的难点不在于API本身,而在于多进程/多线程环境下的并发控制。记住一句话:共享状态是万恶之源。尽量减少共享,必须共享时用原子操作或锁来保护。

嗯,这一章的内容就到这里。文件I/O的进阶知识,说白了就是理解内核怎么管理文件描述符和偏移量,以及怎么在并发环境下安全地操作它们。下次你遇到文件读写的问题,不妨先想想:是不是竞争条件在作怪?