ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux文件编程进阶:文件描述符、高效I/O与并发锁实战

Linux文件编程进阶:文件描述符、高效I/O与并发锁实战 1. 项目概述与整体思路1.1 为什么还要写一篇“文件编程2”上一篇文章里我们把open、read、write、close这四个基本操作翻来覆去讲了一遍基本的读写流程应该已经跑通了。但实际做Linux系统编程的时候你会发现光会这几个接口远远不够——程序跑起来是一回事跑得稳、跑得快、扛得住并发是另一回事。这篇“文件编程2”就是奔着深水区去的。我打算从文件描述符在内核里的真实运作机制讲起再到高效读写的策略选型、文件状态的精细管理、并发场景下的文件锁、目录遍历的完整实现最后把实战里最容易踩的坑集中排查一遍。主要内容面向以下几类读者刚学完基础文件I/O想把底层机制彻底弄明白的学生工作中需要处理日志、配置、数据文件的运维和嵌入式开发工程师准备Linux面试想在文件系统、缓冲、锁这类高频考点上建立体系化认知的求职者。这一篇尽量做到“看完能直接用”代码示例都是我实际验证过的参数和边界条件也标得比较细。1.2 本篇的定位与内容地图很多教学材料把文件编程讲成了一个一个孤立API的说明书这是最坑的地方。实际上文件编程是一座冰山水面之上是几个系统调用水面之下是虚拟文件系统VFS、页缓存page cache、文件描述符表、inode、锁机制这些东西。只背API面试一问就露馅出了问题也不知道从哪里排查。所以这篇的设计思路是每个API都往下挖一层讲清楚它为什么这么设计、内核帮你做了什么、哪些情况下它会坑你。内容组织大致分成六块文件描述符与内核对象的关系高效读写的几种路径和取舍文件元数据、权限和时间的管理文件锁与并发控制目录遍历与文件搜索的工程实现高频故障的定位与排查最后再用一个综合统计程序把前面这些点串起来跑一遍算是这份内容的一个结课作业。2. 文件描述符的底层机制2.1 一张表引发的“血案”文件描述符file descriptorFD本质上是一个非负整数。听起来很简单但这个数字背后挂着一整套内核对象。我画个链路你马上就能理解进程的文件描述符表 → 打开的文件描述file对象 → inode真正磁盘上的文件文件描述符表每个进程一张数组结构下标就是FD。表项里主要记录“这个fd指向哪个file对象”以及一些标志位比如FD_CLOEXEC。这张表默认大小为1024可以调但数量有限所以用完必须close否则就是fd泄漏。file对象内核里一个结构体保存着文件偏移量offset、打开模式O_RDONLY等、状态标志。注意它不保存文件名也不直接对应磁盘数据。inode真正描述磁盘文件实体的结构包含文件大小、权限、时间戳、数据块位置等。多个file对象可以指向同一个inode。理解这三层结构很多诡异现象都能解释。比如你fork()之后父子进程共享的是同一个file对象所以它们的读写偏移是联动的——父进程读了一部分子进程接着读到的就是后面那段这往往不是你想要的。再比如dup()和dup2()只是复制了fd表项让两个fd指向同一个file对象偏移量自然也是共享的。2.2 标准输入输出其实也是文件新手往往意识不到printf往屏幕打印这个动作底层就是把数据写到文件描述符1stdout上。Linux把所有东西都抽象成文件普通文件、目录、设备、管道、socket统统可以用文件I/O那一套来操作。三个默认打开的fd文件描述符名称默认设备用途0stdin终端键盘标准输入1stdout终端屏幕标准输出2stderr终端屏幕错误输出stderr和stdout分开设计是有讲究的——数据输出和错误日志应该能分别重定向。比如你在shell里执行./app result.log 2 error.logstdout进了result.logstderr进了error.log两边互不干扰。这个能力在服务端程序里太常用了。2.3 重定向的底层玩法dup2理解了fd表之后重定向的原理就非常直白把某个fd的表项改成指向另一个file对象。#include unistd.h #include fcntl.h #include stdio.h int main() { // 打开日志文件fd为3 int fd open(output.log, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); return 1; } // 复制fd表项把fd1stdout指向output.log的file对象 dup2(fd, STDOUT_FILENO); // 这句话不会打到屏幕上而是写入output.log printf(hello, file programming\n); // 注意老fd3还需要关闭 close(fd); return 0; }这里有三个点值得强调。第一dup2执行后fd 1和fd 3指向同一个file对象偏移共享所以后面printf打印的内容会接着open时的偏移继续写。第二要close(fd)否则fd 3就一直占着表项短时间没事但程序如果长期反复执行类似操作fd表会被慢慢耗光。第三实际工程里建议使用dup3它可以额外设置O_CLOEXEC防止exec执行新程序时泄漏fd。注意fd不是“越大越好”也不是“一定要从3开始”。它只是当前进程fd表里第一个空闲位置的下标。0/1/2已经被标准流占了所以第一次open基本上都会返回3。2.4 FD_CLOEXEC你以为关了其实没关但凡写过网络服务程序的人大概率遇到过fd泄漏的问题。一个典型场景是主进程打开了一个配置文件或socket然后fork()exec()去执行外部程序。问题是exec会替换进程映像但不自动关闭除O_CLOEXEC之外的打开fd——于是新程序继承了一堆它根本用不到的文件描述符。解决方式有两个在open时直接加O_CLOEXEC标志一次搞定open之后用fcntl(fd, F_SETFD, FD_CLOEXEC)手动设置。第一种更优雅因为它是原子的不存在“打开到设置之间被并发线程fork出去”的窗口期。这个细节在高并发、多线程、需要拉起子进程的系统里尤其重要。3. 高效读写的核心路径与参数选择3.1 read/write的隐藏成本初学文件编程时很多人习惯写一个循环一次读1个字节处理完再读下一个。对于普通文本文件这么写能跑但效率很低——因为每一次read/write都是一次系统调用意味着要从用户态切换到内核态做一堆检查再切换回来。如果一次读写的数据量很小系统调用开销占比就会非常大整体吞吐量惨不忍睹。我实测过一个简单场景用每次1字节的方式读一个100MB的文件和每次读1MB相比耗时差距能到几十倍。这个差距在机械硬盘时代还能被磁盘寻道时间掩盖一部分到了NVMe SSD上纯负载就是CPU内核态切换和页缓存拷贝的开销差异特别明显。所以第一条优化准则尽量单次读写大块数据。如果数据天然是一行一行的就用getline或自带的缓冲读取如果涉及网络包解析这类定长结构就按结构体大小来读。3.2 stdio缓冲 vs 裸系统调用C标准库的fopen/fread/fwrite里其实内置了用户态缓冲机制。默认情况下读文件时fread会一次向内核申请一大块数据填充到用户缓冲区后续的读取直接从缓冲区拿只有缓冲区空了才再次发起系统调用。而open/read/write是裸的系统调用没有用户态缓冲。那是不是就是说“用stdio一定比用裸read快”其实不能这么简单地画等号。stdio的优势是减少用户态和内核态切换次数但如果你自己用一个大缓冲区比如64KB一次read就读满也不存在频繁切换的问题。反过来stdio在某些场景下反而坑——比如你要用write配合lseek做精确偏移写入最好直接用裸系统调用不要和stdio混用否则缓冲区和offset的状态一旦对不上排错非常痛苦。这里我建议一个朴素的判断标准读写位置线性前进、顺序处理文本或二进制块 → 用fread/fwrite或者自建大缓冲都可以需要随机访问、精确控制写入位置、和mmap混用 → 直接用open/read/write/lseek需要零拷贝或高性能服务端 → 往下看配合mmap和sendfile。3.3 page cache和O_DIRECT到底什么时候用Linux内核会把读过的磁盘数据缓存在内存中这块缓存叫页缓存page cache。所以你读一遍文件后马上再读一次第二次会明显更快因为数据直接来自内存不碰磁盘。写数据时也是先写page cache内核找机会再刷回磁盘这就是“延迟写”机制。页缓存对绝大多数应用是恩赐但有一种情况是例外数据库、虚拟机镜像这类对一致性要求极高、或者有自己内存管理的应用。如果数据在page cache里进程突然断电或崩溃缓存中的数据可能还没落盘。虽然fsync能强制刷盘但大文件频繁fsync的性能代价也是实实在在的。这时可以考虑O_DIRECT标志它告诉内核这次I/O绕过page cache直接在用户缓冲区和磁盘设备之间传输。好处是避免了double caching内核缓存一份、应用自己又缓存一份坏处是要求用户缓冲区内存对齐、块大小对齐且传输通常按块粒度进行随便拿个小buffer去读很可能返回EINVAL。O_DIRECT什么时候用我个人的经验是自己有完善的缓存和应用层管理数据库、消息队列时值得用单纯读文件做分析别用页缓存的加速收益远大于double cache的损耗基准测试场景用它能测出相对真实的磁盘性能。3.4 pread/pwrite多线程读写同一fd的救星read/write有个隐藏问题它们依赖file对象里共享的偏移量。如果两个线程同时对一个fd执行read线程A读完后偏移后移线程B的读也会从后移处开始两个线程的数据就错乱了。常规解法是加锁保护偏移量或者每个线程单独open一个fd。其实还有一个更便捷的接口ssize_t pread(int fd, void *buf, size_t count, off_t offset); ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);pread等价于先lseek到指定位置再read但区别在于它不改变file对象里的当前偏移量也不会被其他线程的偏移操作干扰。这一设计对并发场景极其友好。多线程各读各的区间完全不需要锁。提示pread/pwrite并不是“线程安全”的万能药——底层内核设备驱动、文件系统本身是否安全是另一回事但至少从接口层避免了偏移量竞争。此外注意传给它的offset是位置参数不会更新当前偏移所以之后再用read还是从原来的地方接着读。3.5 mmap把文件映射进内存mmap可以把文件的一部分或全部映射到进程的地址空间之后你对这块内存的读写内核会通过缺页异常机制帮你和文件完成数据交换。好处有三点省去了反复read/write的用户内核拷贝读取时通过内存访问就能拿数据方便随机访问数组式操作不需要每次lseek多个进程映射同一个文件天然共享内存进程间通信也变得简单。用mmap读文件的代码框架#include sys/mman.h #include sys/stat.h #include fcntl.h #include unistd.h #include stdio.h int main() { int fd open(data.bin, O_RDONLY); if (fd 0) { perror(open); return 1; } struct stat st; if (fstat(fd, st) ! 0) { perror(fstat); return 1; } size_t len st.st_size; char *addr mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); if (addr MAP_FAILED) { perror(mmap); return 1; } // 直接当数组访问 printf(first byte: %c\n, addr[0]); munmap(addr, len); close(fd); return 0; }用mmap有几个坑第一文件大小不能为0映射长度为0时mmap必然失败第二映射后在别的进程里把文件truncate到比映射短你访问到超出文件末尾的页时会收到SIGBUS程序直接崩没有任何出错机会第三MAP_PRIVATE的写操作只改内存不改文件如果要写回文件得用MAP_SHARED并且注意调msync刷盘。在生产环境里mmap常用于日志索引、配置文件解析、共享内存队列这类场景。它绝对不是万能的但用好了之后代码简洁度和性能表现都会明显上一个台阶。4. 文件元数据、权限与时间的精细管理4.1 stat家族的正确打开方式有时候我们需要知道文件的大小、类型、权限、修改时间而不是文件内容本身。这时候stat就该登场了。它有三个变体函数作用stat(path, buf)通过路径获取元数据跟随符号链接lstat(path, buf)和stat类似但遇到符号链接时获取链接本身的信息fstat(fd, buf)通过已打开的fd获取元数据struct stat里有几个字段需要重点说明st_size文件大小字节对符号链接来说是链接目标路径字符串的长度st_mode文件类型和权限位永远不要直接拿它和数字硬比较要用宏S_ISREG()、S_ISDIR()、S_ISLNK()等st_mtime最后修改时间很多人把它和st_ctime搞混。st_ctime是状态变更时间权限、链接数、属主修改都会刷新它不只是内容修改st_nlink硬链接数理解了硬链接才能理解为什么“删了文件但空间没释放”。4.2 删除文件不等于空间释放这个坑在运维里太常见了磁盘空间告警你找到一个大日志文件执行rm -rf结果df -h一看空间根本没释放。原因很简单——删除一个文件名的操作本质是减少它的硬链接计数。如果此时还有一个进程通过fd持有这个文件那么inode不会真正回收磁盘空间也就不会释放。验证方法# 找到占着已删除文件的进程 lsof | grep deleted解决方法是找到对应的进程重启或让它重新打开文件空间才会真正释放。这个知识点在日志切割场景里尤其重要因为常见的日志轮转工具就是通过创建新文件、让进程重开fd来解决的而不是简单rm。4.3 chmod与umask权限不是“你设成什么就是什么”chmod大家都知道是改权限但有个细节90%的人会忽略——open创建文件时传的mode参数实际上要经过umask过滤。如果当前进程的umask是022那你传0666最终得到的权限是0666 ~022 0644。所以别惊讶为什么open传了0644却得到0600之类的先看一眼umask。修改文件权限用chmod或fchmod#include sys/stat.h int main() { // 给文件的所有者加写权限移除其他用户的执行权限 chmod(test.txt, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); return 0; }用符号的方式表达很清楚可读性强。还有一个容易忽视的点目录的执行权限x表示能否进入目录如果没有x权限哪怕你对目录有rw权限也进不去。所以给目录设755、文件设644这个经典组合是有底层逻辑的。4.4 时间戳的三个视角Linux文件有3个时间戳很多面试题拿这个考人字段含义触发条件st_atime访问时间read、mmap读取、目录遍历st_mtime修改时间内容被写入或截断st_ctime状态变更时间权限、属主、链接数等改变有个经典问题为什么tar解包文件后文件的atime和mtime变了严格来说tar在解包后可以通过utime或utimes恢复mtime但它不会恢复ctime因为ctime是内核在文件元数据变更时自动刷新的不允许应用程序随意设置。理解了这一点以后用find -mtime判断文件修改新旧时就不会误解了。5. 文件锁与并发控制5.1 为什么需要文件锁多进程同时写一个文件时如果每个进程都各自open同一个文件并直接write最后的文件内容可能就是你中有我、我中有你的大杂烩。哪怕每个write的数据不长也架不住多个进程穿插执行。解决思路之一就是文件锁。它分两类建议性锁advisory lock锁只是“约定”Linux内核不会强制拦截绕过锁的普通read/write。也就是说别人如果不遵守锁协议照样能读写锁只对“也会去申请锁”的进程有效强制性锁mandatory lock内核强制检查每次I/O是否和锁冲突但Linux对它的支持不完整、性能开销大实际工程中基本不用。于是你应该明白了文件锁是“君子协定”配合良好的工程规范才能发挥价值。5.2 flock与fcntl记录锁的差异两种最常用的锁APIflock()简单、整文件加锁锁和“打开的文件描述”绑定同一个进程用不同fd去锁同一个文件也可能会互相阻塞取决于内核版本和fd是否复制过fcntl(F_SETLK/F_SETLKW)POSIX记录锁可以锁文件某个字节区间锁和“进程inode”绑定同一进程重复加锁不会死锁而是把已有锁替换成新锁。实际选型时我的建议是锁粒度小、需要锁定某个区间就选fcntl只要求互斥、不关心精细区间用flock就够了。两者互不兼容所以在同一套代码里不要混用。fcntl记录锁一个比较实用的示例#include fcntl.h #include unistd.h #include stdio.h void lock_region(int fd, off_t start, off_t len) { struct flock lock {0}; lock.l_type F_WRLCK; // 写锁 lock.l_whence SEEK_SET; // 起点以文件头为基准 lock.l_start start; lock.l_len len; // F_SETLK 非阻塞F_SETLKW 阻塞等待 if (fcntl(fd, F_SETLK, lock) -1) { perror(fcntl lock failed); return; } printf(lock region [%lld, %lld) ok\n, (long long)start, (long long)len); }注意l_len为0时表示从l_start到文件末尾的整个剩余区域。这个特性可以帮你锁住“当前文件末尾到未来扩展部分”非常实用。5.3 锁的释放和死锁锁跟着fd走close(fd)后进程对这个文件持有的所有锁都会被释放。这一点很容易被忽略——你以为锁在某个fd上结果某个地方顺手close了同一个文件的其他fd锁直接就没了。fcntl的锁还是进程级的不是fd级。fork出来的子进程不会继承父进程的锁这在设计多进程协作时需要额外小心。此外当进程在持有记录锁时如果又去申请另一个锁可能形成死锁进程A锁了区域1等区域2进程B锁了区域2等区域1。fcntl的F_SETLKW在可能死锁时会让其中一个调用返回EDEADLK应用层要做好错误处理而不是一股脑重试。注意文件锁只是并发控制的一种手段。对于“多个进程同时向文件尾部追加日志”这种特定场景更简单高效的方式是open时加O_APPEND标志——POSIX保证每次write不会覆盖别人的数据偏移定位和写入操作原子完成。善用O_APPEND和O_EXCL往往比全套锁机制更可靠。6. 目录遍历与文件搜索的工程实现6.1 opendir/readdir的基本姿势遍历目录常用POSIX标准接口#include dirent.h #include stdio.h int main() { DIR *dir opendir(.); if (!dir) { perror(opendir); return 1; } struct dirent *entry; while ((entry readdir(dir)) ! NULL) { // d_type: DT_REG(普通文件), DT_DIR(目录), DT_LNK(符号链接)... printf(%s (type%d)\n, entry-d_name, entry-d_type); } closedir(dir); return 0; }重点说几个容易踩的细节readdir返回的条目包含了.和..递归遍历时一定要跳过否则死循环不是技术问题而是逻辑错误d_type在大多数本地文件系统上有效但某些特殊文件系统如某些网络文件系统可能返回DT_UNKNOWN这时你需要用stat或lstat再确认类型别想当然目录项的顺序是文件系统布局决定的不代表字母顺序。如果需要有序输出自己排序。6.2 递归遍历目录一个可用的示例这里我写一个递归统计目录下普通文件数量和总大小的程序代码不复杂但能串联起这一整篇的好几个知识点#include dirent.h #include sys/stat.h #include stdio.h #include string.h #include limits.h static long total_files 0; static long long total_size 0; void walk_dir(const char *path) { DIR *dir opendir(path); if (!dir) { perror(opendir); return; } struct dirent *entry; while ((entry readdir(dir)) ! NULL) { if (strcmp(entry-d_name, .) 0 || strcmp(entry-d_name, ..) 0) { continue; } char fullpath[PATH_MAX]; snprintf(fullpath, sizeof(fullpath), %s/%s, path, entry-d_name); struct stat st; // 不跟随符号链接避免循环 if (lstat(fullpath, st) ! 0) { continue; } if (S_ISDIR(st.st_mode)) { walk_dir(fullpath); } else if (S_ISREG(st.st_mode)) { total_files; total_size st.st_size; } // 其他类型文件暂时忽略 } closedir(dir); } int main() { walk_dir(.); printf(total files: %ld\n, total_files); printf(total size: %lld bytes\n, total_size); return 0; }这里我用lstat而不是stat这是刻意的——符号链接可能指向一个目录如果跟随碰到循环链接就会无限递归。用lstat只识别真正的目录符号链接统一跳过至少在统计场景下逻辑是安全的。6.3 nftw站在巨人的肩膀上自己写递归遍历当然能加深理解但生产环境我更推荐nftw()file tree walk。它帮你处理了递归、排序、符号链接、挂载点跨越等一堆细节代码量少得多#define _XOPEN_SOURCE 500 #include ftw.h #include stdio.h static int count_regular(const char *path, const struct stat *st, int flag, struct FTW *ftw) { if (flag FTW_F S_ISREG(st-st_mode)) { printf(%s (%lld bytes)\n, path, (long long)st-st_size); } return 0; // 返回非0可以提前终止遍历 } int main() { // 第三个参数是同时打开fd的最大数量最后一个0表示不跟随符号链接 nftw(., count_regular, 20, 0); return 0; }要注意的是nftw回调用FTW_F表示普通文件、FTW_D表示目录、FTW_SL表示符号链接第四个参数是深度信息。设置FTW_DEPTH在第四个参数中可以改成后序遍历适合先处理子文件再处理目录本身的场景。7. 文件内容的高级操作7.1 lseek移动读写的指针普通文件的读写顺序是线性的但很多场景需要跳到文件的某个指定位置去读写比如修改二进制文件里的某个字段。这时就用lseekoff_t new_offset lseek(fd, 100, SEEK_SET); // 从文件头偏移100字节 lseek(fd, 50, SEEK_CUR); // 相对于当前位置前移50字节 lseek(fd, -20, SEEK_END); // 定位到末尾往前20字节处lseek返回新的偏移量。一个常见的坑是lseek能定位到的偏移超过文件当前末尾。如果你在这种“空洞”位置写入数据文件中间的这段数据会被填充为0磁盘上却不一定真实分配空间这在文件系统里叫“稀疏文件”。从应用层看文件很大实际占用磁盘很小——测试或程序里可以利用但排查磁盘空间时别只看文件大小。7.2 ftruncate截断与预分配ftruncate(fd, length)可以把文件截断为指定长度。如果length小于文件当前大小多余的部分会被丢弃反之则自动扩展到目标长度扩展部分填0。这个接口经常用来“预分配”文件空间。比如一个记录型程序知道今天要写10万条数据每条固定100字节那就可以先ftruncate(fd, 10*10000*100)把文件撑大后面用pwrite在指定偏移写入。好处是减少了文件系统反复分配块的开销也能提前暴露“磁盘空间不足”的错误。但要注意预分配会真实占用磁盘空间别把稀疏文件那套逻辑混进来。7.3 与时间赛跑fsync与fdatasync前文提过write写入的数据先进page cache什么时候落盘由内核决定。如果程序写完立刻断电或崩溃丢了数据只能自认倒霉。为了让关键数据真正落盘需要强制刷盘fsync(fd)把数据和元数据inode等都刷到磁盘fdatasync(fd)只刷数据不刷非必要元数据通常更快sync()把全部脏页刷盘性能代价大一般不建议用。这里有个认知要更新即便你调用fsync硬件层面的写缓存仍然可能“谎报军情”说写完了其实还在盘片缓冲区。所以数据库这类系统通常还会配合断电保护机制。应用层能做的就是“在真正需要持久化的节点上调用fsync”每次提交事务、写重要配置文件后不要省这个调用。7.4 零拷贝思想的简单切入在高性能场景下如果要把一个文件完整发送到socket传统的做法是read(file_fd, buf, len) → write(sock_fd, buf, len)这一来一回数据在用户态和内核态之间至少拷贝了两遍。零拷贝思想的本质是让数据在内核里直接从一个文件描述符转到另一个减少用户态拷贝。sendfile就是这样一个接口ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它把in_fd比如文件的内容直接送到out_fd比如socket。用sendfile传文件既能减小CPU负载也能提升吞吐。这个知识点在面试中属于“加分项”理解了它再去看如今各种框架里的零拷贝优化就会有“原来如此”的感觉。8. 常见问题与排查技巧实录8.1 文件描述符泄漏怎么快速定位症状通常是“程序跑着跑着就报Too many open files”。快速定位方式# 查看某进程打开了哪些文件 ls -l /proc/pid/fd # 统计打开数量 ls /proc/pid/fd | wc -l # 查看进程当前fd限制 cat /proc/pid/limits | grep open files常见的泄漏源头有三种打开后忘了close打开失败后没有正确清理异常分支提前return导致close永远执行不到。代码层面最好统一设计“清理出口”比如C语言里用goto cleanup或者封装资源管理结构在函数结束时统一释放。8.2 磁盘空间够write却报ENOSPCdf -h看的是块空间但df -i才能真正反映inode数量。inode耗尽后文件系统无法创建新文件或扩展文件write也会返回ENOSPC尽管剩余磁盘空间还很大。这对日志服务器、大量小文件场景特别常见。排查时记住两条命令df -h df -i如果-i显示100%已用就得清理大量小文件或者未来创建文件系统时把inode数量调大。这个例子很适合作为面试题答案的一部分报ENOSPC先排查磁盘容量再排查inode。8.3 EINTR被信号打断的读写长时间运行的守护进程几乎必见EINTR。比如一次read正在等数据这时进程收到一个信号如果信号处理函数被调用且没有设置SA_RESTARTread会返回-1errno为EINTR。很多新手直接把EINTR当错误处理程序行为就会变怪。正确的做法是在循环里判断如果errno EINTR就重试ssize_t ret; do { ret read(fd, buf, sizeof(buf)); } while (ret -1 errno EINTR);同样的问题也会出现在write、open、fcntl等系统调用上。这也是为什么长时间运行的服务端程序很多都有个read_retry或writen的封装函数统一处理短读和EINTR问题。8.4 读写顺序错乱先OOO后AIO文件编程里“越简单越容易错”的典型就是读写顺序。比如一个程序先write一段配置紧跟着read同一个文件自以为能读到刚写的内容——大多数时候确实可以因为page cache是一致的。但如果有人在中间调用了O_DIRECT绕过缓存或者在多进程场景里另一个fd读到的就不是最新数据。这里我想提醒的是跨进程、跨fd的一致性必须由你主动管理不要把“碰巧能读到”当成“总是能读到”。关键节点上fsync加锁老生常谈但值得反复强调。8.5 文本文件的内容“乱了”如果你用二进制方式读取Windows下编辑的文本文件每行结尾的\r\n会让你在Linux下看到末尾多个^M。解决方式是用dos2unix转换或者在读入时自行处理\r和\n。还有编码问题UTF-8和GBK混在一起打开就是乱码。文件编程不只是“读写字节”理解文件的数据组织方式和外部工具链的约束也是合格程序员的基本功。9. 综合实战写一个文件统计与搜索工具到了这里我把这一整篇的知识点揉在一起做一个实用的工具给定一个目录递归统计其中各类文件的数量、总大小并展示占用空间最大的5个文件。完整代码如下#define _XOPEN_SOURCE 500 #include ftw.h #include stdio.h #include stdlib.h #include string.h #include limits.h typedef struct { char path[PATH_MAX]; long long size; } FileInfo; static FileInfo top[5]; static long regular_count 0; static long dir_count 0; static long long total_size 0; static void add_top(const char *path, long long size) { int i; for (i 0; i 5; i) { if (top[i].size 0) break; if (size top[i].size) break; } if (i 5) return; // 后移 for (int j 4; j i; j--) { top[j] top[j - 1]; } strncpy(top[i].path, path, PATH_MAX - 1); top[i].path[PATH_MAX - 1] \0; top[i].size size; } static int walk_cb(const char *path, const struct stat *st, int flag, struct FTW *ftw) { if (flag FTW_D) { dir_count; } else if (flag FTW_F) { regular_count; total_size st-st_size; add_top(path, st-st_size); } return 0; } int main(int argc, char *argv[]) { const char *root (argc 1) ? argv[1] : .; if (nftw(root, walk_cb, 64, 0) ! 0) { perror(nftw); return 1; } printf(directory count: %ld\n, dir_count); printf(regular file count: %ld\n, regular_count); printf(total size: %lld bytes (%.2f MB)\n, total_size, total_size / 1024.0 / 1024.0); printf(top 5 largest files:\n); for (int i 0; i 5; i) { if (top[i].size 0) { printf( %s (%lld bytes)\n, top[i].path, top[i].size); } } return 0; }这个示例主要演示三件事nftw回调方式的灵活用法用结构体数组维护一个TopN窗口把本篇讲的stat、大小统计、路径处理综合起来。编一个简单MakefileCC gcc CFLAGS -Wall -O2 all: filescan filescan: filescan.c $(CC) $(CFLAGS) -o $ $^ clean: rm -f filescan运行make ./filescan /usr/share/doc就能看到目录数、普通文件数、总大小和Top5大文件。这个小工具扩展性很强改改回调里判断条件就能实现按后缀名统计、按最近修改时间归并等功能。10. 我在实际使用中的几点体会文件编程入门简单精通很难难在“系统里的一切都是文件”这个理念的贯彻。写着写着你就发现socket要用read/write设备节点也要用read/write甚至进程间通信的管道也是read/write——文件I/O的功底决定了你理解整个Linux系统编程的上限。如果让我给你一个学习路径上的建议那就是不要止步于API调用遇到一个现象就去想内核层的对象关系为什么fork之后偏移量共享为什么删了文件空间不释放为什么write之后要fsync这些问题背后的机制搞清楚了你写的代码会自然上一个档次。最后分享一个实用技巧调试文件相关问题时多开几个strace观察系统调用和返回值比你在代码里打一百个printf都有用。文件编程的成败往往不在语法而在对系统行为边界的理解。
返回列表