
POSIX 低级 I/O 核心内容从文件描述符到系统调用的底层真相做服务端开发和系统编程的人早晚会碰到一个绕不过去的坎——POSIX 低级 I/O。我刚入行时以为文件读写就是fopen/fwrite/fclose那一套直到线上日志丢失、管道写了一半被中断、多进程写同一个文件互相覆盖才被迫把open/read/write/lseek这些老朋友重新研究了一遍。这篇东西不聊标准库里那些封装好的舒服接口只聊站在它们背后的那一层文件描述符级别、直接面向内核系统调用的 I/O 操作。它能帮你解决什么问题简单说就是让你真正掌控数据从用户态到内核态再到硬件的完整路径搞清楚缓冲、偏移、阻塞、原子性这些概念在操作系统层面的真实含义。适合正在学 Unix/Linux 环境编程的学生、写网络服务或存储组件的后端工程师以及那些被诡异 I/O 问题折磨过的排障老手。1. POSIX 低级 I/O 到底在解决什么问题1.1 文件描述符一切 I/O 的入口在 POSIX 世界里所有 I/O 操作几乎都围绕一个东西展开文件描述符file descriptor简称 fd。它是一个非负整数在进程内部充当打开文件的句柄。初次接触会觉得很抽象——为什么一个整数就能代表一个文件你可以把它理解成去餐厅吃饭拿到的等位号你不用自己去厨房盯着锅只需要拿着号牌服务员就能找到你的桌子。操作系统也一样内核维护着一张打开文件表每个打开的文件、管道、套接字、设备都对应表里的一个条目fd 就是这张表的索引。进程启动时内核默认分配 0、1、2 三个 fd分别对应标准输入、标准输出、标准错误。之后每次调用open、socket、pipe、accept等函数内核返回的都是当前进程中最小可用的 fd。从 3 开始一路递增直到进程打开的文件数达到上限。这个上限可以查也可以用setrlimit调整但那是另一码事。理解 fd 的存在是理解低级 I/O 的第一道门槛。因为低级 I/O 的一切操作——读、写、定位、加锁、设置非阻塞——都是基于 fd 进行的而不是基于文件路径。路径只是开门时用一下打开后门锁就换成了 fd 这把钥匙。这带来一个关键特性文件一旦打开即使原始路径被改名或删除已经打开的 fd 仍然有效仍可读写。这个特性在高频日志轮转、临时文件处理中非常有用后面实操部分我会专门用到。1.2 低级 I/O 与标准 I/O 的分工C 标准库的fread、fwrite、fgets等函数以及 C 的iostream属于标准 I/O。它们内部底层最终还是调用read、write这些系统调用但中间加了一层用户态缓冲。缓冲的好处是明显减少系统调用次数批量搬运数据适合频繁的小尺寸读写。缺点是你看到的进度不代表真实的落盘进度数据可能还躺在用户态缓冲区里没到内核更没到磁盘。低级 I/O则直接调用read、write、open、close、lseek、fcntl、dup等 POSIX 函数。这些函数是系统调用的封装没有用户态缓冲每次调用直接陷入内核。好处是路径短、行为可预期、可以直接控制 O_APPEND、O_NONBLOCK 等标志坏处是每次调用有开销不适合小尺寸高频操作。选哪个我的习惯是如果要写文件系统上的普通文件且数据量密集、粒度小优先用标准 I/O顺手还能用setvbuf调缓冲策略如果是网络 socket、管道、设备文件、需要精确控制偏移和同步的日志文件或者要处理多进程共享写入的场景必须用低级 I/O。这里面没有谁替代谁的问题是尺有所短寸有所长。1.3 为什么必须懂它真实场景里躲不开的地方很多看起来莫名其妙的问题本质上都是低级 I/O 层面的问题。举几个我实际踩过的场景日志文件内容交错丢失多个进程同时以追加模式写同一个日志文件如果没用 O_APPEND而是seek 到文件尾再 write两个进程的 seek 和 write 并不是一个原子操作必然互相覆盖。管道 pipe 写入凭空失败管道有容量上限写端可能因为读端消费慢而阻塞也可能因为读端关闭而收到 SIGPIPE 信号直接退出程序。标准 I/O 的缓冲机制会掩盖这个过程让你误以为是程序逻辑问题。socket 发送部分字节write一个 100KB 的包内核可能只接受了 64KB 就返回了剩余部分要自己循环发送。很多人第一次遇到都懵为什么write明明返回正数数据却对不上文件被删了fd 还在写日志按天滚动删除旧文件后进程依旧持有 fd 写旧 inode磁盘空间只增不减直到进程重启才恢复。这些问题不深入理解 fd 和系统调用的语义排查起来会非常痛苦。低级 I/O 不是让你放弃高级 API而是让你在出问题时能往下看一层看到数据真正流动的地方。2. 核心 API 逐个拆解从 open 到 close 的完整链路2.1 open开门的方式决定后续体验open是低级 I/O 的起点原型如下#include fcntl.h #include sys/stat.h int open(const char *pathname, int flags, ... /* mode_t mode */);flags分为两部分访问模式和附加标志。访问模式用O_RDONLY、O_WRONLY、O_RDWR三选一不可组合。附加标志则五花八门常用的有O_CREAT文件不存在则创建配合第三个参数mode指定权限。注意mode不是最终权限它要经过umask过滤实际权限是mode ~umask。这坑了我好几次——明明指定了 0666结果因为 umask 是 0022文件实际是 0644。O_EXCL与O_CREAT搭配使用如果文件已存在则open失败。这是创建专属文件的原子操作常用于防止多进程同时创建同一个临时文件比先 stat 判断再 open要安全得多。O_TRUNC以写方式打开时将文件长度截断为 0。常规操作但注意它和O_APPEND同时使用时某些系统上行为会变得微妙——按 POSIX 标准O_TRUNC和O_APPEND可以同时存在截断依然生效追加写仍从新文件尾开始。O_APPEND写操作总是从文件末尾追加。重点说一下这个标志保证原子追加。意思是每次写之前内核会把当前偏移移到文件末尾整个移到末尾写入是一个原子步骤多进程同时写也不会互相覆盖。这是解决日志交错问题最便宜的手段。O_NONBLOCK以非阻塞模式打开。对普通文件没什么用普通文件总是阻塞的但对管道、FIFO、设备文件意义重大读操作没有数据时立即返回EAGAIN而不是阻塞等待。O_CLOEXEC设置执行exec时自动关闭 fd。这不是 POSIX 标配但在 Linux 上是强烈建议加的——否则 fork exec 后子进程会意外继承一堆用不到的 fd造成资源泄漏甚至安全隐患。返回值上open成功返回新的 fd失败返回 -1 并设置errno。常见的EACCES表示权限不足EEXIST表示O_CREAT | O_EXCL但文件已存在ENOENT表示路径不存在或指向的符号链接失效。// 正确姿势打开一个写入日志的文件不存在则创建权限受 umask 约束 int fd open(/var/log/myapp.log, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); if (fd 0) { // 根据 errno 输出具体失败原因 }一个小经验open的flags一定要按需组合宁可多写几个标志也不要在业务上用反正能读能写就行的心态。尤其是初始化阶段多花一分钟想清楚 O_APPEND、O_TRUNC、O_EXCL 的语义能省掉后面排障的一整天。2.2 read 与 write低级 I/O 里最容易被坑的两个函数read和write是 I/O 的核心动作实现上简单的让人意外——就一个用户缓冲指针和字节数#include unistd.h ssize_t read(int fd, void *buf, size_t count); ssize_t write(int fd, const void *buf, size_t count);难点不在原型而在返回值。先说read返回0表示读到文件末尾EOF或对端关闭了连接socket 场景。不要把它当作错误但它往往意味着数据流结束程序需要据此收尾。返回-1出错看errno。特别要警惕EINTR系统调用被信号中断数据没读写到不是严重错误适当重试即可。返回0 n count读到了部分数据。这不是错误尤其在 socket 和管道上非常常见。不能假设一次read就能读够你要的字节数必须循环调用直到读满目标字节数或返回 0。再说write返回n count写入了部分字节。这种情况在管道和 socket 上很常见比如管道容量不足、socket 发送缓冲区已满。正确做法是循环写入把剩余部分继续写完。返回-1常见EINTR被信号中断、EAGAIN非阻塞模式下暂时无法写入、EPIPE管道读端已关闭同时进程会收到 SIGPIPE 信号。返回0这在 write 里几乎不会出现标准未定义但如果你看到了多半是缓冲区长度本来就为 0。下面是一个可靠的全量写函数避免部分写入导致的文件损坏ssize_t write_all(int fd, const void *buf, size_t len) { const char *p buf; size_t written 0; while (written len) { ssize_t n write(fd, p written, len - written); if (n 0) { if (errno EINTR) { continue; // 被信号中断重试 } return -1; // 真正的错误比如 EPIPE、EIO } if (n 0) { // 理论上写 0 字节不该发生做个保护 return -1; } written n; } return written; }这个函数看起来平淡无奇却是很多线上事故的救星。我用它替换过好几个写日志偶尔少一行的问题——不是文件顺序问题而是单次write返回了部分字节而原程序没做循环处理。read同理建议封装一个read_full用于读取固定长度的头部或记录配合read本身的语义处理 EOFssize_t read_full(int fd, void *buf, size_t len) { char *p buf; size_t got 0; while (got len) { ssize_t n read(fd, p got, len - got); if (n 0) { if (errno EINTR) { continue; } return -1; } if (n 0) { break; // EOF未读够 len } got n; } return got; }2.3 lseek文件偏移的隐形指针每个打开的文件描述符都有一个文件偏移file offset记录了当前读写位置。lseek用来移动这个偏移#include unistd.h off_t lseek(int fd, off_t offset, int whence);whence有三种SEEK_SET从文件头开始偏移、SEEK_CUR从当前位置偏移、SEEK_END从文件尾开始偏移。offset可以为负数比如lseek(fd, -10, SEEK_END)定位到倒数第 10 个字节。理解文件偏移的关键点它是内核维护的属性不是文件自身的属性。同一个文件被打开两次得到两个 fd各自有独立的偏移。多进程共享同一个 fd比如 fork 出来的子进程继承父进程的 fd偏移则是共享的。lseek只改变偏移不触发任何磁盘 I/O。它的开销极低但调用频繁也会造成无谓的系统调用消耗所以能用 O_APPEND 解决追加问题就不要lseek write组合。把偏移移到文件末尾之后再写入数据会形成文件空洞sparse file。空洞区域没分配磁盘块但读出来是零字节。这特性被拿来模拟大文件也常被误用来制造磁盘空间没满但 df 显示文件很大的怪异现象。对管道、socket、FIFO 调用lseek会失败返回 -1errno为ESPIPE。这是常识但真有人把普通文件的代码直接套在管道上。一个容易被忽略的场景以O_APPEND模式打开文件后lseek可以移动到任意位置但任何write都会无视lseek的结果仍然从文件尾部写入。所以如果你想在文件中某处修改一段内容而又用 O_APPEND 打开那结果必然不符合预期。需要既读又写、且要改特定位置时应该用O_RDWR打开先lseek定位再写。2.4 close 与错误处理收尾比想象中更讲究close很简单但问题往往藏在细节里int close(int fd);第一close 失败要处理吗要。虽然返回 -1 的常见场景不多但 NFS 这类网络文件系统上close可能因为延迟写失败而报告错误此时数据可能没有真正落到服务端。严谨的程序应该在 close 失败时记录错误甚至考虑是否要重试。当然对普通本地文件close 失败的概率极低很多人直接忽略 —— 我承认自己也会忽略但心里要知道这个风险存在。第二关闭文件描述符不代表数据已经落盘。write成功数据只是到了内核的页缓存page cache还没写到磁盘。要保证持久化需要调用fsync(fd)或fdatasync(fd)。fsync把数据和元数据都刷下去fdatasync只刷数据开销更小。对数据库写 WAL 日志、或者对一致性要求高的配置文件这一步不能省。第三被信号打断的 close。这是最容易被忽略的某些情况下尤其是 NFS 上close会返回EINTR。这时候 fd 是否已经被关闭POSIX 标准说状态未定义。Linux 上的实际行为是fd 已经被释放你不能再拿它做任何事但错误码告诉你被中断。正确处理方式是如果 close 因 EINTR 失败不要再调用 close 同一个 fd否则可能意外关闭一个被复用的新 fd多线程场景尤其危险。这个细节知道的人真不多我在面试里问过不少人能答清的寥寥无几。第四关闭逻辑要放在资源管理的最内层。fd 是有限资源泄漏 1000 个进程可能还活着泄漏数万就会碰到EMFILE进程打开文件数超限。排查 fd 泄漏有个土办法ls /proc/pid/fd | wc -l看看数量如果持续上升大概率存在 fd 泄漏逐一检查所有open和accept的路径是否成对出现close。3. 实操手写一个可靠的日志写入器3.1 场景设计多进程写日志的痛点理论知识说了不少现在做一个完整的实战实现一个多进程安全、写入不丢失、崩溃不残留的日志写入器。需求拆解如下多个 worker 进程同时写同一个日志文件要求每行日志完整、不交错、不互相覆盖。如果系统崩溃或进程被 kill -9尽量保证已写入的数据不丢。当然做不到绝对不丢只能做到应用层能做的部分。日志文件超过阈值自动切换到新文件按大小滚动旧文件保留。写入路径短性能不能太差。注意这里刻意不用标准 C 库的fprintf因为 fopen 的用户态缓冲在多进程间是独立的数据到内核的时间不可控做滚动时会漏掉别的进程还没 flush 的数据。用低级 I/O 加 O_APPEND配合多进程共享文件描述符行为可以把控得多。设计上的关键决策用 O_APPEND 而不是 lseek write。前文说过这是原子追加解决多进程交错覆盖的问题。每次写日志都调用 write 一次。虽然系统调用开销存在但日志场景通常不是超高频写可以接受。如果单行日志太长超过 PIPE_BUF 那种情况在普通文件上 O_APPEND 也保证单个 write 是原子的短于一定长度的写入不会交错。对普通文件POSIX 没有强制保证单次 write 的原子性但 Linux 上对于本地文件系统O_APPEND模式下的单次 write 在操作不超过一定大小时是原子的。要保险的话单条日志控制在几十 KB 以内足够。滚动日志时用 rename 而不是直接 close open 新文件。先 rename 旧文件为带时间戳的归档名再创建一个新文件。这样持有旧 fd 的进程仍在往已改名的文件里追加数据等新文件创建后新的 open 会拿到新 fd。这里就会出现前文说的fd 一旦打开路径删除也不影响写入的现象需要配合每隔一段时间重新 open 一次的策略或者通过信号通知各进程重新打开日志文件。每条日志写入后可选 fsync。为了性能默认不 fsync每 1000 条或固定时间间隔刷一次。如果对持久性要求极高比如审计日志则每条都 fdatasync代价是写入吞吐显著下降要能接受再开。3.2 代码实现从参数到每一步的推敲先实现一个简单的版本固定日志路径每次打开文件尾部追加一行。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include errno.h #include time.h #include stdarg.h typedef struct { int fd; off_t max_size; char path[512]; char archive_path[512]; long count_since_fsync; long fsync_interval; } log_writer_t; static int log_writer_roll(log_writer_t *w) { // 1. 先把当前文件改名归档 time_t now time(NULL); struct tm tm_now; localtime_r(now, tm_now); char stamp[64]; strftime(stamp, sizeof(stamp), %Y%m%d_%H%M%S, tm_now); // 归档文件名加时间戳 随机数避免同秒冲突 int r rand() % 10000; snprintf(w-archive_path, sizeof(w-archive_path), %s.%s.%04d, w-path, stamp, r); // 2. 当前 fd 先关掉再重开新文件保证所有人都用新文件 close(w-fd); if (rename(w-path, w-archive_path) 0) { // rename 失败也要能继续比如文件不存在就忽略 if (errno ! ENOENT) { return -1; } } // 3. 重新打开新日志文件 w-fd open(w-path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); if (w-fd 0) { return -1; } w-count_since_fsync 0; // 这里可以加一个写日志动作记录滚动事件 return 0; } int log_writer_init(log_writer_t *w, const char *path, off_t max_size) { memset(w, 0, sizeof(*w)); snprintf(w-path, sizeof(w-path), %s, path); w-max_size max_size; w-fsync_interval 1000; w-fd open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); if (w-fd 0) { return -1; } // 检查初始大小 off_t cur lseek(w-fd, 0, SEEK_END); if (cur w-max_size w-max_size 0) { log_writer_roll(w); } return 0; } int log_writer_write(log_writer_t *w, const char *fmt, ...) { char buf[4096]; va_list ap; va_start(ap, fmt); int len vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); if (len 0 || (size_t)len sizeof(buf)) { // 超长截断也能写但说明调用方式不对 len sizeof(buf) - 1; } // 使用 write_all 避免部分写入 const char *p buf; size_t remaining len; while (remaining 0) { ssize_t n write(w-fd, p, remaining); if (n 0) { if (errno EINTR) { continue; } return -1; } p n; remaining - n; } // 计数并根据策略 fsync w-count_since_fsync; if (w-fsync_interval 0 w-count_since_fsync w-fsync_interval) { // fdatasync 比 fsync 开销小刷数据和必要的元数据 fdatasync(w-fd); w-count_since_fsync 0; } // 大小检查 off_t cur lseek(w-fd, 0, SEEK_END); if (cur w-max_size w-max_size 0) { log_writer_roll(w); } return 0; }代码里有几个设计点值得展开为什么用O_CLOEXEC日志 fd 如果不加这个标志fork 后子进程会继承它万一子进程又 exec 了别的程序那个程序就会拿着一个日志 fd 却不知情最坏情况是子进程退出时把 fd 关掉而父进程那个 fd 仍然存活fd 是进程级概念父子各有各的引用不会直接出问题但会污染子进程的 fd 空间。加上 O_CLOEXEC 一了百了。为什么滚动是close → rename → open而不是rename → open如果先 rename 再 close那在 close 的窗口里当前进程没有 fd其他进程还在写旧文件。滚动后所有新写入都进新文件但滚动前正在写一半的一条日志可能会被拆到两个文件里旧文件里写一半新文件里写另一半。要彻底避免这个问题严谨做法是所有进程在收到滚动信号后先写完当前正在写的日志再统一执行滚动逻辑。上面这个单函数版本只解决了文件不丢失没解决日志跨文件拆分的细粒度一致性。在实际系统里我通常会再加一个写日志前先判断是否需要滚动的检查把滚动决策放到写日志的同一线程里做避免边写边滚的竞态。为什么write_all要用循环普通文件上的 write 在绝大多数情况下一次写入全部字节但极端情况下比如磁盘配额满、文件系统错误等还是会出现部分写入。日志场景下一条日志被截断排查起来非常恶心所以宁可多写几行循环代码。3.3 验证与压测怎么确认它是可靠的写完代码不能直接上线先本地验证几件事多进程并发写入测试起 10 个进程每个进程写 10 万行包含 PID 和自增序号的行然后检查总行数是否等于 100 万并且每一行都不残缺、没有跨进程交错。写个脚本统计# 用 grep 检查行数和完整性 wc -l /tmp/test.log grep -c ^PID /tmp/test.log # 检查每行是否完整 awk length($0) 30 {bad} END {print bad0} /tmp/test.log如果 interleaving 严重awk 会抓到一堆长短不一的行如果 write 有残缺grep -c的行数会远小于总写入行数。滚动测试把 max_size 设得很小比如 1KB连续写入几千行观察日志目录是否产生了多个归档文件原文件是否一直存在、内容是否完整。这一步最容易发现rolling 后新数据被写进归档文件这类错误。kill -9 测试写一半强行 kill看下次启动后文件是否还正常有没有明显的内核页缓存丢数据问题。注意kill -9 进程后内核页缓存里的数据会在之后某个时间由内核刷盘不一定会立刻丢但程序没能做收尾工作数据落盘时机不可控。如果要求高可靠性需要配合 fsync 策略。strace 验证系统调用顺序strace -f -e traceopen,write,lseek,close ./your_program观察滚动时是否出现奇怪的系统调用顺序。实测下来这个简单的日志写入器在单进程写、双进程并发写两个场景下都能保证行不交错配合每 1000 行一次 fdatasync吞吐大约能到几十万行/秒满足绝大多数业务场景。4. 常见错误码与排查技巧实录4.1 高频错误码速查表低级 I/O 出问题时报错全靠errno。这些年我遇到的高频错误码整理成一张速查表errno含义常见场景处理建议EINTR系统调用被信号中断任何阻塞调用都可能出现一般重试即可注意 close 的 EINTR 特殊处理EAGAIN资源暂时不可用非阻塞 fd 在无数据时 read或缓冲区满时 write等事件就绪后再读/写或轮询重试EBADFfd 无效用了已关闭的 fd或 fd 与当前操作模式不匹配检查资源生命周期打印 fd 栈ENOENT路径不存在open 不存在的文件且没加 O_CREAT或路径拼错先确认绝对路径EACCES权限不足文件权限、目录权限、umask 不对检查进程是否有文件权限EPIPE管道/套接字读端关闭对方已关闭连接还在 write需要处理 SIGPIPE 信号或忽略它并捕捉 EPIPEEMFILE进程 fd 数达上限fd 泄漏或单进程文件数超限排查 fd 泄漏检查 pctrl 限制ENOSPC磁盘满写入时磁盘空间不足清理磁盘或扩容还要防止日志无限增长EIO底层 I/O 错误设备错误、文件系统异常检查 dmesg 判断是否为硬件问题ESPIPE对管道调用 lseek不能对管道/socket 做定位逻辑错误检查操作对象这张表贴代码旁边排障会快很多。比起大段文字描述表格查起来省事。4.2 用 strace 看清系统调用全过程遇到低级 I/O 问题第一件事就是strace。它能探测进程发起了哪些系统调用、参数是什么、返回值是什么、errno 是什么堪称系统调用层面的抓包工具。基本用法# 跟踪一个程序的读写行为输出到文件 strace -ff -tt -T -o /tmp/trace.log ./your_program # 跟踪正在运行的进程 strace -p 12345 -e traceread,write,open,close,fcntl,lseek关键字段解读第一列是系统调用名和参数比如openat(AT_FDCWD, /tmp/test.log, O_WRONLY|O_CREAT|O_APPEND|O_CLOEXEC, 0644)。返回行是 3表示返回 fd 3。出错行是 -1 EAGAIN (Resource temporarily unavailable)带着 errno 解释。举一个实际案例有同事反馈程序偶尔卡死strace 输出显示进程阻塞在write(4, ..., 8192)一直没有返回。排查发现问题的 fd 对应的文件在 NFS 挂载点下NFS 服务端故障内核中的文件 I/O 一直等待。这时不禁意识到阻塞的不只是网络 socket普通文件在特定文件系统下也能阻塞很久。strace 直接揭示了本质。strace 对小程序异常退出尤其有用没有日志、没有 dump但你能看到程序到底在哪个read收到了EOF哪个write返回了EPIPE。4.3 几个必须避开的经典坑第一坑fd 被提前关闭又被意外复用。比如某段代码调了close(fd)但没把 fd 清成 -1后面又有open成功返回同一个正整数结果后续的写操作写到了错误对象上。这是我见过最经典的 fd 使用错误。应对方法所有 fd 变量初始化为 -1close 成功或失败后立刻赋 -1用 Resource Acquisition Is InitializationRAII风格的封装把 fd 生命周期交给对象管理。第二坑SIGPIPE 杀死进程。往一个读端已经关闭的管道或 socket 里写数据内核会给进程发送 SIGPIPE默认动作是终止进程。很多服务端程序莫名其妙退出就是这原因。解决方式在程序初始化时忽略 SIGPIPEsignal(SIGPIPE, SIG_IGN)然后在 write 返回 -1、errno 为 EPIPE 时自行处理。注意忽略 SIGPIPE 是全局性的要确保所有用网络的地方都接受 EPIPE 并妥善善后。第三坑非阻塞 fd 上忙轮询。O_NONBLOCK打开 fd 后read 没数据立即返回 EAGAIN。有人图省事用while (1) { read; if (EAGAIN) continue; }死等CPU 打满系统卡死。正确姿势是用poll、epoll、select等事件通知机制或者配合合理的usleep退避。低级 I/O 的低级指的是接口层级不是让你放弃事件循环。第四坑双写导致日志交错但没察觉。这个前面讲过但值得再强调多进程写同一个文件除非每个 write 都很小小于文件系统原子保证的块大小Linux 上常见为 4096 或更小且使用 O_APPEND否则还是可能交错。日志文件一般单条不会太大O_APPEND 够用但如果写超过几十 KB 的块在本地文件系统上也不一定保证原子性最好加锁或采用每进程写独立文件再合并的方案。第五坑lseek write 不是原子操作。在同一个 fd 上如果两个线程或两个进程共享 fd同时做定位到文件尾再写可能后写的线程覆盖先写的数据。O_APPEND 的重要性就在这里它把定位和写合并成了一个原子操作。只要你能用 O_APPEND 表达需求就不要自己手动 lseek write。5. 进阶非阻塞、文件锁与 dup 家族5.1 O_NONBLOCK 与就绪通知阻塞 I/O 容易理解read 没数据就等着write 腾不出空间就等着。非阻塞模式下调用立即返回没数据就是EAGAIN有空间就是正常读写。非阻塞不是让你轮询而是配合多路复用机制poll、epoll、select使用达到有事件才处理的效果。低级 I/O 层面设置非阻塞有两条路子// 1. open 时直接指定 int fd open(/dev/ttyUSB0, O_RDWR | O_NONBLOCK); // 2. 用 fcntl 在打开后设置 int flags fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags | O_NONBLOCK);注意F_GETFL和F_SETFL是 fcntl 的基础用法但操作的是 fd 的文件状态标志不是文件权限。O_NONBLOCK、O_APPEND、O_ASYNC这些是可以动态改的O_CREAT、O_EXCL是open时的参数不可以通过 fcntl 更改。这里有个常见误区普通文件上 O_NONBLOCK 的 read 不会返回 EAGAIN。内核里普通文件的 read 总是立即有结果的要么数据要么 EOF所以 O_NONBLOCK 对常规文件读写没有明显作用反而可能在磁盘慢 I/O 时造成困惑。非阻塞主要用于管道、FIFO、设备文件和网络 socket。// poll 多路复用的基础用法等待可读事件 struct pollfd pfd { .fd fd, .events POLLIN, }; int ret poll(pfd, 1, 1000); // 超时 1 秒 if (ret 0 (pfd.revents POLLIN)) { // 可以安全 read不会阻塞也不会返回 EAGAIN }5.2 fcntl 文件锁多进程协作的边界控制O_APPEND 解决的是追加写不覆盖的问题但很多场景需要更复杂的协作多个进程要读改写同一个文件、要互斥创建资源、要协调命名冲突。这时候就要用到fcntl记录锁。最基本的用法是通过F_SETLK设置锁F_GETLK查询锁F_UNLCK释放锁struct flock lock { .l_type F_WRLCK, // F_RDLCK 读锁F_WRLCK 写锁F_UNLCK 解锁 .l_whence SEEK_SET, .l_start 0, .l_len 0, // 0 表示到文件末尾 }; // 设置写锁非阻塞版本如果被占用立即返回 EAGAIN if (fcntl(fd, F_SETLK, lock) 0) { if (errno EAGAIN || errno EACCES) { // 别处已经持有锁 } } // 阻塞版本等待直到拿到锁 if (fcntl(fd, F_SETLKW, lock) 0) { // 出错 }文件锁的几个关键语义我踩过之后才真正理解锁是跟进程绑定的不是跟 fd 绑定的。同一进程对同一文件多次加锁后加的锁会替换前面的锁进程退出或任意 fd 关闭时进程持有的所有记录锁都会被释放。这意味着你不能在两次 open 之间用锁做多进程互斥——后 open 的文件描述符不影响已有锁。读锁和写锁遵循常规读写冲突规则多个读锁可以共存写锁和任何锁互斥。锁范围由 l_start、l_whence、l_len 确定支持对文件任意区域加锁。注意 l_len 0 表示从起点到文件末尾而且这个末尾是动态的——文件增长了锁也会覆盖新增长的区域。锁不是强制性的除非文件以强制锁方式挂载Linux 上需要在挂载时指定-o mand。默认情况下文件锁只是协作锁只对同样使用 fcntl 的程序有效。一个程序如果不检查锁、只管 write它是不会被阻止的。因此锁适合用来协调自己人之间的协作不适合作为安全机制。文件锁最适合的场景就是单实例守护进程启动时打开一个锁文件尝试加写锁失败就说明已有实例在跑直接退出。这个模式我用过很多次比检查 PID 文件可靠得多因为锁由内核管理进程崩溃后锁会自动消失不会出现PID 文件还在但进程已经不在了的误判。int single_instance_lock(const char *path) { int fd open(path, O_RDWR | O_CREAT, 0644); if (fd 0) return -1; struct flock lock { .l_type F_WRLCK, .l_whence SEEK_SET, .l_start 0, .l_len 0, }; if (fcntl(fd, F_SETLK, lock) 0) { close(fd); return -1; // 已被占用 } // 锁已持有可以顺便把自己的 PID 写进锁文件 // 注意 truncate 后重新写但保留锁 if (ftruncate(fd, 0) 0) { char pidbuf[32]; int len snprintf(pidbuf, sizeof(pidbuf), %d\n, getpid()); write(fd, pidbuf, len); } // fd 不能关闭保持持有 return fd; }5.3 dup/dup2重定向的核心手段dup和dup2做的事情很纯粹复制一个 fd让两个 fd 指向同一个内核文件表项。复制后的两个 fd 共享文件偏移和文件状态标志但 fd 编号不同。int new_fd dup(old_fd); // 新 fd 是当前最小可用编号 int new_fd dup2(old_fd, target_fd); // 指定新 fd 编号若 target_fd 已打开则先关闭这玩意儿有什么用最经典的场景是重定向标准输出要把程序的 stdout 从一个普通 fd 重定向到文件可以先把目标文件打开获得 fd 5然后dup2(5, STDOUT_FILENO)之后所有printf的内容都会写进文件。这个技巧在很多安静的程序需要输出落到文件的工具里大量使用。另一个常见场景是实现 fi 时间共享 read/write先dup(fd)获取一个共享偏移的新 fd在子进程里关闭原 fd只保留复制出来的那个。这可以控制一个进程组内谁还有权操作这个文件。还有一个隐蔽但实用的场景标准错误和标准输出的合并。把dup2(fd, STDERR_FILENO)和dup2(fd, STDOUT_FILENO)指向同一个文件 fd两条输出流就都进同一处。要注意的是如果只考虑普通文件的读写输出重定向其实也可以用缓冲区操作完成但 dup2 能精确控制 fd 级别的关联对 exec 前的环境设置尤其关键——因为子进程 exec 新程序时只能继承 fd无法继承逻辑上的输出流重定向关系。最后说一个可能引发困惑的点dup出来的 fd 和原 fd 共用一个文件偏移所以用read其中一个 fd 读了 N 字节另一个 fd 的偏移也前进 N 字节。这和open同一个路径两次得到的两个独立 fd 完全是两回事。用dup做双缓冲读写时要格外小心偏移共享。说点我自己的感受。每次在代码里看到有人写fopen就以为文件处理万事大吉我都想提一句标准库帮你省了烦恼也帮你藏了细节。真正处理过高并发日志、调过诡异 NFS 断连、排查过 fd 泄漏之后才会明白低级 I/O 的这些原始借口反而是最可靠的锚点。文件描述符、文件偏移、原子追加、错误码这些概念在纸上理解很容易到线上排障时才是真正考验。如果你正在被某个 I/O 怪毛病折磨不妨先用strace看看具体到哪个系统调用、哪个 errno大概率能少走不少弯路。写代码时顺手把read_all、write_all、fd 资源封装做好长期看收益比什么都大。