文件I/O进阶:lseek、文件共享、重定向与fcntl
各位同学,今天我们来聊聊文件I/O里那些「进阶」的东西。说实话,很多人学完open、read、write就觉得文件操作也就这样了。但真正到了项目里,你会发现——嗯,事情没那么简单。
我记得刚入行那会儿,写一个日志收集程序,需要同时往多个文件描述符里写数据。当时我天真地以为,只要把文件描述符复制一份就行了。结果呢?文件指针乱跳,日志全写串了。后来才搞明白,原来文件描述符背后还有这么多门道。
今天这一章,我们就来把这些「门道」彻底讲清楚。
1. lseek:文件指针的「任意门」
lseek这个系统调用,说白了就是移动文件读写位置。你想想看,文件就像一盘磁带,read/weread/write都是从当前位置开始操作的。但有时候,你想跳到文件开头、跳到末尾、或者跳到某个特定位置——这时候就需要lseek出场了。
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
参数whence有三个选项:
- SEEK_SET:从文件开头算起,offset就是绝对位置
- SEEK_CUR:从当前位置算起,offset是相对偏移
- SEEK_END:从文件末尾算起,offset可以是负数
返回值是移动后的位置(相对于文件开头)。如果出错,返回-1。
一个常见的坑:lseek只能用于普通文件,不能用于管道、socket、终端设备。我在项目中遇到过有人试图对stdin做lseek,结果返回-1,程序直接崩了。
还有一个有意思的特性——空洞文件。如果你用lseek跳到文件末尾之后很远的位置,然后write一些数据,中间那段没写数据的区域就是「空洞」。读空洞区域会返回0(即读到EOF),但空洞本身并不占用磁盘空间。
// 创建一个1GB的空洞文件,实际占用几乎为0
int fd = open("huge_file", O_WRONLY | O_CREAT, 0644);
lseek(fd, 1ULL << 30, SEEK_SET); // 跳到1GB位置
write(fd, "end", 3);
close(fd);
这个技巧在下载工具、虚拟机镜像文件里用得很多。我曾经用这个特性实现过一个稀疏日志系统,磁盘占用直接降了90%。
2. 文件共享:多个文件描述符指向同一个文件
这里有个核心概念,我建议大家一定要理解透彻——文件描述符、文件表项、inode这三者的关系。
简单来说:文件描述符是进程里的一个整数索引,指向内核中的文件表项;文件表项里记录了文件偏移、状态标志等信息;而文件表项又指向底层的inode(即真正的文件数据)。
当多个文件描述符指向同一个文件表项时,它们共享文件偏移。比如fork之后,父子进程共享同一个文件表项,所以一个进程读文件,另一个进程的偏移也会跟着变。
但如果两个文件描述符指向不同的文件表项(比如两次open同一个文件),它们各自维护自己的偏移,互不干扰。
// 情况1:共享偏移
int fd = open("test.txt", O_RDWR);
pid_t pid = fork();
if (pid == 0) {
read(fd, buf, 10); // 子进程读10字节
} else {
wait(NULL);
read(fd, buf, 10); // 父进程从第10字节开始读
}
// 情况2:独立偏移
int fd1 = open("test.txt", O_RDWR);
int fd2 = open("test.txt", O_RDWR);
// fd1和fd2各自维护自己的偏移
我曾经踩过的坑:多线程环境下,多个线程共用一个文件描述符,不加锁直接write。结果数据交错写入,日志文件乱成一团。后来才意识到,虽然write本身是原子的(对于小于PIPE_BUF的数据),但多个线程的write之间没有原子性保证。
3. 原子操作:避免「先检查后使用」的竞态
什么叫原子操作?就是要么全部执行,要么完全不执行,中间不会被其他操作打断。
文件I/O里最经典的原子操作需求是——追加写入。如果你先lseek到文件末尾,再write,这两步之间可能被其他进程打断。正确的做法是:open时加上O_APPEND标志。
// 错误做法:非原子追加
lseek(fd, 0, SEEK_END);
write(fd, data, len); // 中间可能被其他进程插入数据
// 正确做法:原子追加
int fd = open("log.txt", O_WRONLY | O_APPEND);
write(fd, data, len); // 内核保证每次write都从末尾开始
另一个原子操作是O_CREAT | O_EXCL组合。用这个组合打开文件时,如果文件已存在,open直接返回错误。这比先检查文件是否存在再创建要安全得多——避免了TOCTOU(Time of Check, Time of Use)问题。
避坑指南:我曾经写过一个守护进程,用O_CREAT | O_EXCL来创建PID文件,确保只有一个实例在运行。结果有一次系统崩溃后,PID文件残留,导致进程无法重启。后来我改成先unlink再创建,或者用flock来做进程锁。
4. dup和dup2:文件描述符的「分身术」
dup和dup2的作用是复制一个文件描述符。复制之后,两个描述符指向同一个文件表项,共享文件偏移和状态标志。
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
- dup:返回当前可用的最小文件描述符编号
- dup2:把oldfd复制到指定的newfd上。如果newfd已经打开,先关闭它
这玩意儿最经典的应用就是重定向。比如你想把标准输出重定向到一个文件:
int fd = open("output.txt", O_WRONLY | O_CREAT, 0644);
close(STDOUT_FILENO); // 关闭标准输出
dup2(fd, STDOUT_FILENO); // 把fd复制到标准输出
close(fd); // 关闭原来的fd
printf("这行字会写到文件里\n"); // 现在printf输出到文件了
我记得在写一个网络服务器时,需要把子进程的标准输出重定向到socket。用dup2几行代码就搞定了,比什么管道、临时文件优雅多了。
注意:dup2和dup3(Linux特有)的区别在于,dup3可以指定close-on-exec标志。如果你不想让复制后的描述符被子进程继承,用dup3加上O_CLOEXEC。
5. fcntl:文件描述符的「瑞士军刀」
fcntl函数能干的事情太多了——修改文件描述符的属性、设置文件锁、获取/设置文件状态标志……我甚至觉得,它是Linux系统编程里最被低估的函数之一。
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
常用的cmd有这些:
| cmd | 作用 | 示例 |
|---|---|---|
| F_DUPFD | 复制文件描述符(类似dup) | fcntl(fd, F_DUPFD, 10); |
| F_GETFD / F_SETFD | 获取/设置文件描述符标志 | fcntl(fd, F_SETFD, FD_CLOEXEC); |
| F_GETFL / F_SETFL | 获取/设置文件状态标志 | fcntl(fd, F_SETFL, O_NONBLOCK); |
| F_GETLK / F_SETLK / F_SETLKW | 文件记录锁 | fcntl(fd, F_SETLK, &lock); |
我个人最常用的是F_SETFL来设置非阻塞I/O。比如打开一个设备文件时忘了加O_NONBLOCK,事后可以用fcntl补上:
int flags = fcntl(fd, F_GETFL);
flags |= O_NONBLOCK;
fcntl(fd, F_SETFL, flags);
还有FD_CLOEXEC这个标志。设置之后,执行exec时这个文件描述符会自动关闭。我建议所有不需要被子进程继承的描述符都加上这个标志——否则子进程里莫名其妙多了一堆打开的文件,很容易出问题。
我曾经踩过的坑:用fcntl设置文件锁时,记得检查返回值。有一次我忘了检查,锁没加上,程序还继续往下跑,结果两个进程同时写同一个文件,数据全乱了。文件锁不是银弹,但它能帮你避免很多并发问题。
知识体系总览
下面这张图,我把本章的核心知识点串起来了。你可以把它当作一个「思维导图」,复习时对照着看:
好了,这一章的内容就到这里。lseek、文件共享、原子操作、dup/dup2、fcntl——这几个知识点单独看都不难,但组合起来能解决很多实际问题。我建议你动手写几个小例子,比如用dup2实现一个简单的shell重定向,或者用fcntl给日志文件加个锁。光看不练,永远记不住。