从竞态到原子:pread/pwrite 如何重塑高效文件 I/O?

发布于:2025-09-04 ⋅ 阅读:(16) ⋅ 点赞:(0)

在日常的文件 I/O 编程中,我们最熟悉的莫过于 read()write() 系统调用。它们是处理文件操作的基石。然而,在多线程或需要精确控制文件偏移量的场景下,这两个基础调用可能会显得笨拙甚至导致问题。这就是 Linux 和 Unix 系统提供 pread()pwrite() 的原因所在。

本文将深入探讨这两个强大的系统调用,帮助你提升 I/O 操作的效率和正确性。

1. 传统方式的痛点:read/write + lseek

在介绍新朋友之前,我们先回顾一下老朋友的工作方式。传统的文件读取流程通常是这样的:

  1. 使用 lseek() 系统调用将文件偏移量移动到目标位置。
  2. 调用 read() 从当前偏移量开始读取数据,read() 会自动推进偏移量。

写入流程也是类似的 lseek() + write()

这种方法在单线程环境下工作良好,但在多线程环境下有一个致命的缺陷:竞争条件(Race Condition)。

竞争条件示例

想象以下场景,两个线程共享同一个文件描述符(fd):

  1. 线程 A 希望读取文件 100 字节处的数据。
  2. 线程 B 希望读取文件 200 字节处的数据。
  3. 线程 A 成功调用 lseek(fd, 100, SEEK_SET),将偏移量设置为 100。
  4. 就在此时,操作系统调度器暂停了线程 A,并唤醒了线程 B。
  5. 线程 B 执行 lseek(fd, 200, SEEK_SET),成功将偏移量修改为 200。
  6. 线程 B 调用 read(fd, buffer, size),从偏移量 200 处开始读取。
  7. 线程 B 完成操作,操作系统再次调度线程 A。
  8. 线程 A 从它停止的地方继续,调用 read(fd, buffer, size)
  9. 问题来了! 文件偏移量现在是由线程 B 设置的 200,而不是线程 A 期望的 100。线程 A 读到了错误的数据。
时间线     线程A操作                   线程B操作                   文件偏移量
----------------------------------------------------------------------------
 t0                                                             0
 t1     lseek(fd, 100, SEEK_SET)                               100
 t2                             (线程切换)                      100
 t3                              lseek(fd, 200, SEEK_SET)       200
 t4                              read(fd, buffer, size)         200 + size
 t5     (线程切换)                                               200 + size
 t6     read(fd, buffer, size)                                  200 + size + size

图示:两个线程交替操作共享的文件偏移量,导致数据错乱。线程A本想读取100处的数据,却读到了200+size处的数据。

这是因为文件偏移量是内核中与文件描述符关联的一个属性,是全局共享的状态。传统的 read/write 隐式地使用和修改这个共享状态,从而导致了并发问题。

解决这个问题通常需要引入互斥锁(Mutex) 来保护 lseek + read/write 这个操作序列,使其成为原子操作。但这会增加代码的复杂性和锁的开销。

2. 更优雅的解决方案:pread 和 pwrite

pread() (Positional Read) 和 pwrite() (Positional Write) 就是为了解决上述问题而设计的。它们将"定位(Seek)"和"读写(Read/Write)"两个操作合并为一个单一的、原子的系统调用。

函数原型

#include <unistd.h>

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);
  • fd:文件描述符。
  • buf:用于存储读取数据或待写入数据的缓冲区。
  • count:要读取或写入的字节数。
  • offset关键的参数! 指定从文件的哪个偏移量开始进行读写操作。

核心优势:原子性

最重要的特点是:preadpwrite 在执行时,不会改变文件描述符关联的文件偏移量。

pread(fd, buf, count, offset) 调用严格等价于以下代码序列的原子执行

off_t old_offset = lseek(fd, 0, SEEK_CUR); // 保存原偏移量
lseek(fd, offset, SEEK_SET);
read(fd, buf, count);
lseek(fd, old_offset, SEEK_SET); // 恢复原偏移量

但它是在内核层面一气呵成的,不会被其他线程中断。

这带来了三个巨大好处:

  1. 线程安全:因为它们不依赖也不修改全局的文件偏移量,多个线程可以同时使用同一个文件描述符调用 preadpwrite 而无需任何锁。它们彼此之间不会产生干扰。
  2. 避免副作用:由于文件偏移量保持不变,你可以在调用 pread/pwrite 前后,放心地使用传统的 read/write,而不用担心偏移量被意外修改。
  3. 代码更简洁:无需再手动调用 lseek,代码意图更加清晰——“请直接从 offset 位置读取 count 字节的数据”。

3. 代码示例对比

让我们通过一个简单的例子来感受两者的区别。

任务:从文件的开头和第 100 字节处分别读取 50 字节的数据。

使用传统方式(lseek + read)
// ... 打开文件获得 fd ...
int fd = open("file.txt", O_RDONLY);
char buf1[50], buf2[50];

// 读取开头50字节
lseek(fd, 0, SEEK_SET);   // 手动定位
read(fd, buf1, 50);       // 偏移量变为50

// 读取偏移量100处50字节
lseek(fd, 100, SEEK_SET); // 再次定位(易被其他线程干扰)
read(fd, buf2, 50);       // 偏移量变为150

// 如果你想再回到开头读,又得重新lseek
使用 pread(推荐)
// ... 打开文件获得 fd ...
int fd = open("file.txt", O_RDONLY);
char buf1[50], buf2[50];

pread(fd, buf1, 50, 0);   // 直接从偏移量0读取,不修改全局偏移
pread(fd, buf2, 50, 100); // 直接从偏移量100读取,全局偏移仍为初始值

// 文件偏移量从头到尾都没有被改变过!
// 可以随意混合使用 pread 和传统 read,互不干扰

可以看到,使用 pread 的代码更简洁,意图更明确,并且天生就是线程安全的。

4. 重要注意事项

  1. 偏移量类型offset 参数是 off_t 类型,通常在 32 位系统上是 32 位,在 64 位系统上是 64 位。这意味着它可以支持大于 4GB 的大文件。
  2. 并发写入pwrite 的原子性保证的是"定位+写入"这个动作的原子性,而不是文件内容的原子性。如果你的数据块大小超过了一个磁盘扇区(通常是512字节),一次 pwrite 操作可能最终被分解为多次磁盘写入。如果需要更严格的原子性(如所有数据全部成功或全部失败),需要考虑事务或日志文件系统等其他机制。
  3. 适用文件类型preadpwrite 主要适用于常规文件。对于管道、套接字或某些特殊设备文件,它们可能无法使用(ESPIPE 错误),因为这些对象不支持"寻址"的概念。

5. 总结与适用场景

特性 read/write pread/pwrite
文件偏移量 使用并修改全局偏移量 忽略不修改全局偏移量
线程安全 不安全,需加锁 天生安全
操作原子性 非原子(lseek+IO 原子操作
代码简洁性 需配合 lseek 更简洁,意图更明确

强烈建议在以下场景中使用 preadpwrite

  • 多线程编程:当多个线程操作同一个文件描述符时,这是首选方案。
  • 随机 I/O:需要在文件的不同位置进行读取或写入,特别是多次、跳跃式的访问(例如数据库操作)。
  • 保持偏移量:当你希望在执行一些特定位置的 I/O 后,文件偏移量仍然保持在原来的位置。

总之,preadpwrite 是文件 I/O 工具箱中两颗被低估的明珠。它们提供了更强的线程安全保证和更清晰的编程语义。下次当你需要从文件的特定位置读写数据时,请优先考虑它们,这会让你的代码更加健壮和高效。


网站公告

今日签到

点亮在社区的每一天
去签到