在日常的文件 I/O 编程中,我们最熟悉的莫过于 read()
和 write()
系统调用。它们是处理文件操作的基石。然而,在多线程或需要精确控制文件偏移量的场景下,这两个基础调用可能会显得笨拙甚至导致问题。这就是 Linux 和 Unix 系统提供 pread()
和 pwrite()
的原因所在。
本文将深入探讨这两个强大的系统调用,帮助你提升 I/O 操作的效率和正确性。
1. 传统方式的痛点:read/write + lseek
在介绍新朋友之前,我们先回顾一下老朋友的工作方式。传统的文件读取流程通常是这样的:
- 使用
lseek()
系统调用将文件偏移量移动到目标位置。 - 调用
read()
从当前偏移量开始读取数据,read()
会自动推进偏移量。
写入流程也是类似的 lseek()
+ write()
。
这种方法在单线程环境下工作良好,但在多线程环境下有一个致命的缺陷:竞争条件(Race Condition)。
竞争条件示例
想象以下场景,两个线程共享同一个文件描述符(fd):
- 线程 A 希望读取文件 100 字节处的数据。
- 线程 B 希望读取文件 200 字节处的数据。
- 线程 A 成功调用
lseek(fd, 100, SEEK_SET)
,将偏移量设置为 100。 - 就在此时,操作系统调度器暂停了线程 A,并唤醒了线程 B。
- 线程 B 执行
lseek(fd, 200, SEEK_SET)
,成功将偏移量修改为 200。 - 线程 B 调用
read(fd, buffer, size)
,从偏移量 200 处开始读取。 - 线程 B 完成操作,操作系统再次调度线程 A。
- 线程 A 从它停止的地方继续,调用
read(fd, buffer, size)
。 - 问题来了! 文件偏移量现在是由线程 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
:关键的参数! 指定从文件的哪个偏移量开始进行读写操作。
核心优势:原子性
最重要的特点是:pread
和 pwrite
在执行时,不会改变文件描述符关联的文件偏移量。
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); // 恢复原偏移量
但它是在内核层面一气呵成的,不会被其他线程中断。
这带来了三个巨大好处:
- 线程安全:因为它们不依赖也不修改全局的文件偏移量,多个线程可以同时使用同一个文件描述符调用
pread
或pwrite
而无需任何锁。它们彼此之间不会产生干扰。 - 避免副作用:由于文件偏移量保持不变,你可以在调用
pread
/pwrite
前后,放心地使用传统的read
/write
,而不用担心偏移量被意外修改。 - 代码更简洁:无需再手动调用
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. 重要注意事项
- 偏移量类型:
offset
参数是off_t
类型,通常在 32 位系统上是 32 位,在 64 位系统上是 64 位。这意味着它可以支持大于 4GB 的大文件。 - 并发写入:
pwrite
的原子性保证的是"定位+写入"这个动作的原子性,而不是文件内容的原子性。如果你的数据块大小超过了一个磁盘扇区(通常是512字节),一次pwrite
操作可能最终被分解为多次磁盘写入。如果需要更严格的原子性(如所有数据全部成功或全部失败),需要考虑事务或日志文件系统等其他机制。 - 适用文件类型:
pread
和pwrite
主要适用于常规文件。对于管道、套接字或某些特殊设备文件,它们可能无法使用(ESPIPE
错误),因为这些对象不支持"寻址"的概念。
5. 总结与适用场景
特性 | read /write |
pread /pwrite |
---|---|---|
文件偏移量 | 使用并修改全局偏移量 | 忽略且不修改全局偏移量 |
线程安全 | 不安全,需加锁 | 天生安全 |
操作原子性 | 非原子(lseek+IO ) |
原子操作 |
代码简洁性 | 需配合 lseek |
更简洁,意图更明确 |
强烈建议在以下场景中使用 pread
和 pwrite
:
- 多线程编程:当多个线程操作同一个文件描述符时,这是首选方案。
- 随机 I/O:需要在文件的不同位置进行读取或写入,特别是多次、跳跃式的访问(例如数据库操作)。
- 保持偏移量:当你希望在执行一些特定位置的 I/O 后,文件偏移量仍然保持在原来的位置。
总之,pread
和 pwrite
是文件 I/O 工具箱中两颗被低估的明珠。它们提供了更强的线程安全保证和更清晰的编程语义。下次当你需要从文件的特定位置读写数据时,请优先考虑它们,这会让你的代码更加健壮和高效。